/* Shell Expansion: the line you typed is rewritten before it runs. * * The Seventh Edition sh(1), January 1979, does three things to a command * line before anything is executed, and says so in this order: parameter * substitution, then blank interpretation, then file name generation. Then the * input-output specifications are applied, each one a dup(2) or an open, and * then the command is executed with what is left. POSIX and bash keep that * order. This file runs it. * * expand every unquoted $name or ${name} becomes its value * split the characters that came out of an UNQUOTED expansion are cut at * blanks; quoted ones and the ones typed are never cut * glob each field with an unquoted *, ? or [ is replaced by the file * names it matches, in byte order, or left alone if none match * wire the redirections, left to right, as open, dup2 and close on a * descriptor table, after the pipe's own dup2 * exec what is left is argv, and the program inherits the table * * Three shells, because they disagree in documented places and the page is * about where. dash follows POSIX: a redirection target is not split and not * globbed. bash splits and globs it and refuses anything but one word. bash * --posix does neither, but still refuses a target that expands to nothing. * Each difference is a field of SHELLS below, and each was observed in the * real shells (tests/fixtures/shell-expansion-oracle.json), not assumed. * * What it refuses rather than approximates: command substitution, arithmetic, * tilde expansion, every ${...} form except ${name}, special parameters, brace * expansion (bash's, from csh), here-documents, lists, subshells, assignments, * builtins, any command not in COMMANDS, multi-digit descriptors, patterns or * targets with a slash (except /dev/null), a backslash inside a value, * character classes, equivalence classes and collating symbols in brackets, * the variables a shell sets for itself (SET_AT_START), bash's $'...' and * $"...", and bash's tilde after the = of an assignment-shaped word. * IFS is the default, space, tab and newline, and cannot be changed here; $IFS * expands to that value, as it does in all three shells. */ (function () { "use strict"; /* ---- the world the line runs in --------------------------------------- */ /* One directory, fixed. Dotfiles so the leading-dot rule has something to * refuse, a name with a space so splitting has something to break, two .xyz * files so a pattern can match more than once, and README so that upper * case sorting first is visible. The oracle uses exactly these. */ var FILES = ["README", "a.txt", "b.txt", "c.log", "my file.txt", "x.xyz", "y.xyz", ".hidden.txt", ".profile"]; /* External commands. None is built into dash or bash, which the oracle * asks both shells rather than assuming. */ var COMMANDS = ["ls", "cat", "wc", "grep", "sort", "rm", "cp", "mv", "touch", "head"]; /* Built into both shells, so nothing is exec'd and the descriptors are put * back afterwards. Refused, with that reason. */ var BUILTINS = ["echo", "printf", "cd", "pwd", "test", "[", "true", "false", "read", "export", "set", "unset", "exec", "eval", "type", "kill", "wait", "umask", "shift", "trap", "getopts", "command", "hash", "exit", "return", "break", "continue", ":", ".", "alias", "times", "local"]; var RESERVED = ["if", "then", "else", "elif", "fi", "case", "esac", "for", "while", "until", "do", "done", "in", "!", "{", "}", "function", "select", "time", "[[", "]]", "coproc"]; /* The variables the page starts with. */ var DEFAULT_VARS = { c: "ls", e: "", f: "my file.txt", g: "*.txt *.txt", p: "*", q: "[ab].txt", w: " ", x: "*.xyz" }; /* Where the three shells differ, one field per observed difference. * targetSplitGlob split and glob a redirection target (bash 3.6) * emptyTarget a target that expands to no field at all: * "open" tries to open "" (dash), "ambiguous" refuses * caretNegates [^...] means [!...]; in dash ^ is an ordinary member * dotDirs patterns can match "." and ".." (dash); bash skips them * braces bash runs brace expansion; this page does not * closesBeforeDup the first time a command redirects a descriptor with * >& or <&, dash parks it at 10 or above and closes it * before calling dup2, so if the dup2 fails the * descriptor is left closed. strace shows fcntl(2, * F_DUPFD, 10), close(2), dup2(5, 2) = -1 EBADF. bash * calls dup2 straight away. It decides where the * complaint goes when fd 2 is the one that failed. */ var SHELLS = { dash: { name: "dash", says: "dash", targetSplitGlob: false, emptyTarget: "open", caretNegates: false, dotDirs: true, braces: false, closesBeforeDup: true }, bash: { name: "bash", says: "bash", targetSplitGlob: true, emptyTarget: "ambiguous", caretNegates: true, dotDirs: false, braces: true, closesBeforeDup: false }, posix: { name: "posix", says: "bash --posix", targetSplitGlob: false, emptyTarget: "ambiguous", caretNegates: true, dotDirs: false, braces: true, closesBeforeDup: false } }; var BLANKS = " \t\n"; /* The names each shell has set before it reads the line, asked of the shells * themselves with ${NAME+set} in the environment the recordings ran in * (tests/fixtures/shell-expansion-extra.json; the test holds this list * against it). IFS is left out because the page models it: $IFS is BLANKS. * Every other one is refused by name, because its value is the shell's. */ var SET_AT_START = { dash: ["LC_ALL", "OPTIND", "PATH", "PPID", "PS1", "PS2", "PS4", "PWD"], bash: ["BASH", "BASHOPTS", "BASHPID", "BASH_ARGC", "BASH_ARGV0", "BASH_COMMAND", "BASH_EXECUTION_STRING", "BASH_LOADABLES_PATH", "BASH_MONOSECONDS", "BASH_SUBSHELL", "BASH_VERSINFO", "BASH_VERSION", "COMP_WORDBREAKS", "DIRSTACK", "EPOCHREALTIME", "EPOCHSECONDS", "EUID", "GROUPS", "HISTCMD", "HOSTNAME", "HOSTTYPE", "LC_ALL", "LINENO", "MACHTYPE", "OPTERR", "OPTIND", "OSTYPE", "PATH", "PIPESTATUS", "PPID", "PS4", "PWD", "RANDOM", "SECONDS", "SHELL", "SHELLOPTS", "SHLVL", "SRANDOM", "TERM", "UID", "_"] }; SET_AT_START.posix = SET_AT_START.bash.concat(["POSIXLY_CORRECT"]).sort(); function Refusal(stage, reason) { this.stage = stage; this.reason = reason; } function refuse(stage, reason) { throw new Refusal(stage, reason); } /* ---- reading the line ---------------------------------------------------- */ /* A word is a list of parts: a literal character (quoted or not), a * reference to a variable (quoted or not), or a zero-width mark saying a * quoted region began here. The mark is what keeps "" and "$e" as an empty * argument when splitting would otherwise leave nothing. */ function readDollar(line, i, quoted, parts) { var n = line.charAt(i + 1); if (n === "(") refuse(1, "$( ) and $(( )) run a command or do arithmetic. " + "Both are expansions POSIX puts in the first stage, and this page " + "does not model either."); if (n === "{") { var j = line.indexOf("}", i + 2); if (j < 0) refuse(1, "${ has no closing brace."); var inner = line.slice(i + 2, j); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner)) { refuse(1, "${" + inner + "} is one of the parameter expansion forms with " + "an operator in it. This page models $name and ${name} and nothing else."); } parts.push({ t: "var", name: inner, q: quoted }); return j + 1; } if (/[A-Za-z_]/.test(n)) { var k = i + 1; while (k < line.length && /[A-Za-z0-9_]/.test(line.charAt(k))) k += 1; parts.push({ t: "var", name: line.slice(i + 1, k), q: quoted }); return k; } if (!quoted && (n === "'" || n === "\"")) { refuse(1, "$" + n + " starts bash's $'...' or $\"...\" quoting, which bash and " + "bash --posix read as a quoted string and dash 0.5.12 reads as a $ and a quote. " + "This page models neither."); } if (/[0-9@*#?$!-]/.test(n) && n !== "") { refuse(1, "$" + n + " is a special or positional parameter, which this " + "page does not model."); } parts.push({ t: "lit", c: "$", q: quoted }); return i + 1; } function tokenize(line) { if (typeof line !== "string" || !line.trim()) refuse(0, "There is no command."); if (/[^\x20-\x7e\t]/.test(line)) { refuse(0, "Only printable ASCII is modelled here, so that sorting is byte " + "order and nothing depends on a locale."); } var tokens = []; var cur = null; var i = 0; function start() { if (!cur) cur = { parts: [], quoted: false, start: i }; } function end() { if (cur) { cur.src = line.slice(cur.start, i); tokens.push({ t: "word", w: cur }); cur = null; } } while (i < line.length) { var ch = line.charAt(i); if (ch === " " || ch === "\t") { end(); i += 1; continue; } if (ch === "|") { if (line.charAt(i + 1) === "|") refuse(0, "|| joins two commands into a list, which this page does not model."); end(); tokens.push({ t: "pipe" }); i += 1; continue; } if (ch === ";" || ch === "&" || ch === "(" || ch === ")") { refuse(0, ch === "&" ? "& runs a command in the background, and &> and >& with a file name are " + "bash's own shorthands. This page models one pipeline in the foreground." : "\"" + ch + "\" makes a list or a subshell. This page models one pipeline."); } if (ch === "<" || ch === ">") { var fd = null, at = i; if (cur && cur.parts.length && !cur.quoted && cur.parts.every(function (p) { return p.t === "lit" && !p.q && /[0-9]/.test(p.c); })) { var digits = cur.parts.map(function (p) { return p.c; }).join(""); if (digits.length > 1) refuse(0, "A descriptor number above 9. POSIX only " + "requires 0 to 9, and dash and bash read longer ones differently."); fd = Number(digits); at = cur.start; cur = null; } else end(); var two = line.slice(i, i + 2), op; if (two === "<<") refuse(0, "A here-document. Its word is not expanded the way " + "other targets are, and this page does not model it."); if (two === "<>" || two === ">|") refuse(0, two + " is a POSIX redirection this page does not model."); if (two === ">>" || two === ">&" || two === "<&") op = two; else op = ch; tokens.push({ t: "redir", op: op, fd: fd, src: line.slice(at, i + op.length) }); i += op.length; continue; } start(); if (ch === "'") { var close = line.indexOf("'", i + 1); if (close < 0) refuse(0, "A single quote with no partner."); cur.quoted = true; cur.parts.push({ t: "mark" }); for (var s = i + 1; s < close; s += 1) cur.parts.push({ t: "lit", c: line.charAt(s), q: true }); i = close + 1; continue; } if (ch === "\"") { cur.quoted = true; cur.parts.push({ t: "mark" }); i += 1; for (;;) { if (i >= line.length) refuse(0, "A double quote with no partner."); var d = line.charAt(i); if (d === "\"") { i += 1; break; } if (d === "`") refuse(1, "Backquotes run a command. This page does not model command substitution."); if (d === "\\") { var nx = line.charAt(i + 1); if (nx === "$" || nx === "`" || nx === "\"" || nx === "\\") { cur.parts.push({ t: "lit", c: nx, q: true }); i += 2; } else { cur.parts.push({ t: "lit", c: "\\", q: true }); i += 1; } continue; } if (d === "$") { i = readDollar(line, i, true, cur.parts); continue; } cur.parts.push({ t: "lit", c: d, q: true }); i += 1; } continue; } if (ch === "\\") { if (i + 1 >= line.length) refuse(0, "A backslash at the end of the line continues it onto the next, and there is no next."); cur.quoted = true; cur.parts.push({ t: "lit", c: line.charAt(i + 1), q: true }); i += 2; continue; } if (ch === "`") refuse(1, "Backquotes run a command. This page does not model command substitution."); if (ch === "$") { i = readDollar(line, i, false, cur.parts); continue; } if (cur.parts.length === 0 && ch === "~") { refuse(1, "A word starting with ~ goes through tilde expansion, a POSIX stage " + "this page does not model."); } if (cur.parts.length === 0 && ch === "#") refuse(0, "A word starting with # begins a comment."); cur.parts.push({ t: "lit", c: ch, q: false }); i += 1; } end(); return tokens; } /* Text of a word as typed, without quotes: used to spot reserved words and * assignments, which only count when nothing in them is quoted. */ function bare(word) { if (word.quoted) return null; var s = ""; for (var i = 0; i < word.parts.length; i += 1) { var p = word.parts[i]; if (p.t !== "lit") return null; s += p.c; } return s; } function parse(line) { var tokens = tokenize(line); var members = []; var cur = { words: [], redirs: [] }; for (var i = 0; i < tokens.length; i += 1) { var t = tokens[i]; if (t.t === "pipe") { if (!cur.words.length && !cur.redirs.length) refuse(0, "A | with no command on one side of it."); members.push(cur); cur = { words: [], redirs: [] }; continue; } if (t.t === "redir") { var next = tokens[i + 1]; if (next && next.t === "redir" && next.fd !== null && (t.op === ">&" || t.op === "<&")) { refuse(0, t.src + " has " + next.src + " straight after it. dash reads " + next.src + " as a new redirection and stops with a syntax error; bash reads the " + next.fd + " as the descriptor " + t.src + " copies and then reads " + next.op + " on its own. " + "This page does not choose: put a space after the " + next.fd + "."); } if (!next || next.t !== "word") refuse(0, t.src + " has nothing after it to redirect to."); i += 1; var r = { op: t.op, src: t.src + next.w.src, word: next.w, fd: t.fd !== null ? t.fd : (t.op.charAt(0) === "<" ? 0 : 1) }; if (t.op === ">&" || t.op === "<&") { var b = bare(next.w); if (b === "-") r.kind = "close"; else if (b !== null && /^[0-9]$/.test(b)) { r.kind = "dup"; r.from = Number(b); } else refuse(0, t.op + " followed by \"" + next.w.src + "\". This page models " + "a single descriptor digit or - after " + t.op + ", and bash reads a file " + "name there as both outputs at once, which dash does not."); } else r.kind = "file"; cur.redirs.push(r); continue; } cur.words.push(t.w); } if (!cur.words.length && !cur.redirs.length) refuse(0, "A | with no command on one side of it."); members.push(cur); members.forEach(function (m) { if (!m.words.length) return; var first = bare(m.words[0]); if (first !== null && RESERVED.indexOf(first) >= 0) { refuse(0, "\"" + first + "\" is a reserved word that starts a compound command, " + "which this page does not model."); } if (first !== null && /^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) { refuse(0, "\"" + first + "\" assigns a variable for one command. Set variables " + "in the list instead."); } }); return members; } /* ---- stage 1: expand ------------------------------------------------------ */ /* Characters, each knowing whether it was quoted (q) and whether it came out * of an expansion (x). A mark is a zero-width quoted character. */ function expand(word, vars) { var out = []; word.parts.forEach(function (p) { if (p.t === "mark") { out.push({ c: "", q: true, x: false, mark: true }); return; } if (p.t === "lit") { out.push({ c: p.c, q: p.q, x: false }); return; } var v = Object.prototype.hasOwnProperty.call(vars, p.name) ? String(vars[p.name]) : p.name === "IFS" ? BLANKS : ""; for (var i = 0; i < v.length; i += 1) out.push({ c: v.charAt(i), q: p.q, x: true, from: p.name }); }); return out; } function text(chars) { var s = ""; for (var i = 0; i < chars.length; i += 1) s += chars[i].c; return s; } /* ---- stage 2: split ------------------------------------------------------- */ /* Only characters that are unquoted AND came out of an expansion are field * separators. A field exists only if something, even a mark, is in it, so an * unquoted empty expansion vanishes and a quoted one survives as "". */ function split(chars) { var fields = [], cur = null; chars.forEach(function (ch) { if (!ch.q && ch.x && BLANKS.indexOf(ch.c) >= 0) { if (cur) { fields.push(cur); cur = null; } return; } if (!cur) cur = []; cur.push(ch); }); if (cur) fields.push(cur); return fields; } /* ---- stage 3: glob -------------------------------------------------------- */ function isPattern(chars) { return chars.some(function (ch) { return !ch.q && (ch.c === "*" || ch.c === "?" || ch.c === "["); }); } /* A bracket expression starting just after an unquoted [. Returns null when * there is no closing ], in which case the [ is an ordinary character. */ function bracket(chars, j, shell) { var n = chars.length, neg = false, items = [], first = true; if (j < n && !chars[j].q && (chars[j].c === "!" || (shell.caretNegates && chars[j].c === "^"))) { neg = true; j += 1; } while (j < n) { var ch = chars[j]; if (!ch.q && ch.c === "[" && j + 1 < n && /[:=.]/.test(chars[j + 1].c)) { refuse(3, "\"[" + chars[j + 1].c + "\" inside brackets starts a " + (chars[j + 1].c === ":" ? "character class such as [[:alpha:]], which dash and bash both read" : chars[j + 1].c === "=" ? "an equivalence class such as [[=a=]], which bash reads and dash does not" : "a collating symbol such as [[.a.]], which bash reads and dash does not") + ". This page models ranges, single characters and ! only."); } if (!ch.q && ch.c === "]" && !first) return { neg: neg, items: items, next: j + 1 }; if (j + 2 < n && !chars[j + 1].q && chars[j + 1].c === "-" && !(!chars[j + 2].q && chars[j + 2].c === "]")) { items.push([ch.c, chars[j + 2].c]); j += 3; } else { items.push([ch.c, ch.c]); j += 1; } first = false; } return null; } function compile(field, shell) { var chars = field.filter(function (ch) { return !ch.mark; }); var toks = [], i = 0; while (i < chars.length) { var ch = chars[i]; if (!ch.q && ch.c === "*") { if (!toks.length || toks[toks.length - 1].t !== "star") toks.push({ t: "star" }); i += 1; } else if (!ch.q && ch.c === "?") { toks.push({ t: "any" }); i += 1; } else if (!ch.q && ch.c === "[") { var b = bracket(chars, i + 1, shell); if (b) { toks.push({ t: "set", neg: b.neg, items: b.items }); i = b.next; } else { toks.push({ t: "lit", c: "[" }); i += 1; } } else { toks.push({ t: "lit", c: ch.c }); i += 1; } } return toks; } function inSet(tok, c) { var hit = tok.items.some(function (r) { return r[0] <= c && c <= r[1]; }); return tok.neg ? !hit : hit; } /* Whole-name match. A name that starts with "." matches only a pattern whose * first token is a literal "."; nothing else may stand in for it. */ function matches(toks, name) { if (name.charAt(0) === "." && !(toks.length && toks[0].t === "lit" && toks[0].c === ".")) return false; var memo = {}; function m(ti, ni) { var key = ti + "," + ni; if (key in memo) return memo[key]; var r; if (ti === toks.length) r = ni === name.length; else { var t = toks[ti]; if (t.t === "star") r = m(ti + 1, ni) || (ni < name.length && m(ti, ni + 1)); else if (ni >= name.length) r = false; else if (t.t === "any") r = m(ti + 1, ni + 1); else if (t.t === "set") r = inSet(t, name.charAt(ni)) && m(ti + 1, ni + 1); else r = t.c === name.charAt(ni) && m(ti + 1, ni + 1); } memo[key] = r; return r; } return m(0, 0); } function byteOrder(a, b) { return a < b ? -1 : a > b ? 1 : 0; } function entries(shell) { return shell.dotDirs ? FILES.concat([".", ".."]) : FILES.slice(); } function glob(field, shell) { var literal = text(field); if (!isPattern(field)) return { pattern: false, matched: [], out: [literal] }; if (field.some(function (ch) { return ch.c === "/"; })) { refuse(3, "\"" + literal + "\" is a pattern with a slash in it. The directory " + "here is flat, so this page globs names only."); } var toks = compile(field, shell); var hit = entries(shell).filter(function (name) { return matches(toks, name); }).sort(byteOrder); return { pattern: true, matched: hit, out: hit.length ? hit : [literal], toks: toks, text: literal }; } /* ---- the counterfactual order --------------------------------------------- * No shell does this. It is the same three stages with globbing moved in * front of splitting, so the page can show what the order buys: a value * holding two patterns would be one pattern with a blank in it. */ function globThenSplit(chars, shell) { var g = glob(chars, shell); if (g.pattern && g.matched.length) return g.matched; return split(chars).map(text); } /* ---- stage 4: wire ------------------------------------------------------- */ var FLAGS = { "<": "O_RDONLY", ">": "O_WRONLY|O_CREAT|O_TRUNC", ">>": "O_WRONLY|O_CREAT|O_APPEND" }; function label(desc) { if (desc.kind === "tty") return "terminal (the shell's fd " + desc.origin + ")"; if (desc.kind === "pipe") return "pipe " + desc.pipe + ", " + (desc.end === "w" ? "write" : "read") + " end"; if (desc.kind === "null") return "/dev/null"; return desc.name; } function lowestFree(table) { for (var fd = 0; ; fd += 1) if (!(fd in table)) return fd; } /* What a redirection's word becomes, per shell. Returns {target} or {error}. */ function targetOf(r, vars, shell) { var chars = expand(r.word, vars); var info = { chars: chars }; if (shell.targetSplitGlob) { var outs = []; info.fields = split(chars); info.globbed = info.fields.map(function (f) { return glob(f, shell); }); info.globbed.forEach(function (g) { outs = outs.concat(g.out); }); if (outs.length !== 1) { info.error = { kind: "ambiguous", text: r.word.src + ": ambiguous redirect" }; info.why = outs.length === 0 ? "it expands to no word at all" : "it expands to " + outs.length + " words: " + outs.join(", "); return info; } info.target = outs[0]; } else { if (!chars.length && shell.emptyTarget === "ambiguous") { info.error = { kind: "ambiguous", text: r.word.src + ": ambiguous redirect" }; info.why = "it expands to nothing, not even an empty word"; return info; } info.target = text(chars); } var t = info.target; if (t.indexOf("/") >= 0 && t !== "/dev/null") { refuse(4, "The target \"" + t + "\" has a slash in it. The directory here is " + "flat; the only path this page knows is /dev/null."); } if (t === "." || t === "..") refuse(4, "\"" + t + "\" is a directory, and this page models files."); return info; } /* ---- the whole line ------------------------------------------------------- */ function run(line, vars, shellName, opts) { var shell = SHELLS[shellName]; vars = vars || DEFAULT_VARS; opts = opts || {}; var result = { line: line, shell: shellName, ok: false, refused: null, members: [], dir: [] }; try { if (!shell) refuse(0, "No shell called " + shellName + "."); Object.keys(vars).forEach(function (k) { var v = String(vars[k]); if (v.indexOf("\\") >= 0) { refuse(1, "$" + k + " holds a backslash. Inside a pattern from an expansion " + "it quotes the next character, and this page leaves that out."); } if (/[^\x20-\x7e\t]/.test(v)) refuse(1, "$" + k + " holds a character outside printable ASCII."); }); if (Object.prototype.hasOwnProperty.call(vars, "IFS") && String(vars.IFS) !== BLANKS) { refuse(1, "IFS is changed. This page splits at its default, space, tab and newline, only."); } var members = parse(line); var n = members.length; members.forEach(function (m) { m.words.concat(m.redirs.map(function (r) { return r.word; })).forEach(function (w) { w.parts.forEach(function (p) { if (p.t === "var" && SET_AT_START[shellName].indexOf(p.name) >= 0) { refuse(1, "$" + p.name + " is set before the line runs: " + shell.says + " sets it for itself or takes it from its environment, and its value is the " + "shell's, not the page's. This page models IFS, at its default, and the variables in the list."); } }); // bash, not in POSIX mode, expands ~ after the = of a word shaped like // an assignment, and after a : later in it, even as an argument. if (shellName === "bash") { var lead = ""; for (var pi = 0; pi < w.parts.length; pi += 1) { var p = w.parts[pi]; if (p.t !== "lit" || p.q) break; if (p.c === "=") { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(lead)) break; for (var pj = pi; pj + 1 < w.parts.length; pj += 1) { var a = w.parts[pj], b = w.parts[pj + 1]; if (a.t === "lit" && !a.q && (a.c === "=" || a.c === ":") && b.t === "lit" && !b.q && b.c === "~") { refuse(1, "\"" + w.src + "\" is shaped like an assignment, and bash runs tilde " + "expansion after its = and after a : in it, even as an argument. dash and " + "bash --posix leave the ~ alone. This page does not model tilde expansion."); } } break; } lead += p.c; } } }); }); var created = {}; // name -> index of the member that creates it var effects = {}; // name -> "created" | "truncated" var reads = []; // every file a "<" opened or tried to var patterns = []; // every pattern globbed, and which member globbed it members.forEach(function (m, mi) { var out = { index: mi, words: [], redirs: [], argv: [], calls: [], fds: [], error: null, exec: false }; if (shell.braces) { m.words.concat(m.redirs.map(function (r) { return r.word; })).forEach(function (w) { if (w.parts.some(function (p) { return p.t === "lit" && !p.q && (p.c === "{" || p.c === "}"); })) { refuse(1, "\"" + w.src + "\" has an unquoted brace, and " + shell.says + " runs brace expansion on it first. That stage is bash's, from the C " + "shell, and this page leaves it out. In dash the brace is an ordinary character."); } }); } // Stages 1 to 3 on the words. m.words.forEach(function (w) { var chars = expand(w, vars); var fields = split(chars); var globbed = fields.map(function (f) { return glob(f, shell); }); var outs = []; globbed.forEach(function (g) { outs = outs.concat(g.out); if (g.pattern) patterns.push({ g: g, member: mi }); }); out.words.push({ src: w.src, chars: chars, fields: fields, globbed: globbed, out: outs, otherOrder: opts.otherOrder ? globThenSplit(chars, shell) : null }); out.argv = out.argv.concat(outs); }); if (out.argv.length) { var cmd = out.argv[0]; if (BUILTINS.indexOf(cmd) >= 0) { refuse(5, cmd + " is built into the shell, so nothing is exec'd: the shell " + "rewires its own descriptors, runs it, and puts them back. This page " + "models commands that are exec'd. Try " + COMMANDS.slice(0, 4).join(", ") + "."); } if (COMMANDS.indexOf(cmd) < 0) { refuse(5, "\"" + cmd + "\" is not one of the commands this page offers: " + COMMANDS.join(", ") + "."); } } // Stage 4. The pipe first, then the command's own redirections. var table = { 0: { id: "tty0", kind: "tty", origin: 0, mode: "r", append: false }, 1: { id: "tty1", kind: "tty", origin: 1, mode: "w", append: false }, 2: { id: "tty2", kind: "tty", origin: 2, mode: "w", append: false } }; var seq = 0; if (mi > 0) { table[0] = { id: "p" + mi + "r", kind: "pipe", pipe: mi, end: "r", mode: "r", append: false }; out.calls.push({ call: "dup2", from: "pipe " + mi + " read end", to: 0, text: "dup2(pipe " + mi + " read end, 0)" }); } if (mi < n - 1) { table[1] = { id: "p" + (mi + 1) + "w", kind: "pipe", pipe: mi + 1, end: "w", mode: "w", append: false }; out.calls.push({ call: "dup2", from: "pipe " + (mi + 1) + " write end", to: 1, text: "dup2(pipe " + (mi + 1) + " write end, 1)" }); } if (n > 1) out.calls.push({ call: "close-pipes", text: "close the pipe's own descriptors" }); var touched = {}; for (var ri = 0; ri < m.redirs.length && !out.error; ri += 1) { var r = m.redirs[ri]; var rec = { src: r.src, fd: r.fd, op: r.op, kind: r.kind }; out.redirs.push(rec); var firstTime = !(r.fd in touched); touched[r.fd] = true; if (r.kind === "close") { delete table[r.fd]; out.calls.push({ call: "close", fd: r.fd, text: "close(" + r.fd + ")" }); continue; } if (r.kind === "dup") { if (!(r.from in table) && r.from !== r.fd) { if (shell.closesBeforeDup && firstTime) { delete table[r.fd]; out.calls.push({ call: "close", fd: r.fd, text: "close(" + r.fd + ")", note: "dash parks fd " + r.fd + " to restore later, and closes it" }); } out.calls.push({ call: "dup2", from: r.from, to: r.fd, failed: true, text: "dup2(" + r.from + ", " + r.fd + ") fails: " + r.from + " is not open" }); rec.error = out.error = { kind: "badfd", text: r.from + ": Bad file descriptor" }; break; } if (r.from !== r.fd) { table[r.fd] = table[r.from]; out.calls.push({ call: "dup2", from: r.from, to: r.fd, text: "dup2(" + r.from + ", " + r.fd + ")" }); } continue; } var info = targetOf(r, vars, shell); rec.target = info; (info.globbed || []).forEach(function (g) { if (g.pattern) patterns.push({ g: g, member: mi }); }); if (info.error) { rec.error = out.error = info.error; break; } var name = info.target, desc; var mode = r.op === "<" ? "r" : "w"; if (name === "/dev/null") { desc = { id: "null" + (seq += 1), kind: "null", mode: mode, append: r.op === ">>" }; } else { var exists = FILES.indexOf(name) >= 0 || (name in created); if (r.op === "<") { reads.push({ name: name, member: mi }); if (!exists) { rec.error = out.error = { kind: "open", text: (name === "" ? "\"\"" : name) + ": No such file" }; break; } } else if (name === "") { rec.error = out.error = { kind: "open", text: "\"\": cannot create a file with no name" }; break; } else { if (!exists) { created[name] = mi; effects[name] = "created"; } else if (r.op === ">" && FILES.indexOf(name) >= 0 && !effects[name]) effects[name] = "truncated"; } desc = { id: "f" + mi + "." + (seq += 1), kind: "file", name: name, mode: mode, append: r.op === ">>" }; } var nfd = lowestFree(table); table[nfd] = desc; out.calls.push({ call: "open", name: name, flags: FLAGS[r.op], result: nfd, text: "open(\"" + name + "\", " + FLAGS[r.op] + ") = " + nfd }); if (nfd !== r.fd) { table[r.fd] = desc; delete table[nfd]; out.calls.push({ call: "dup2", from: nfd, to: r.fd, text: "dup2(" + nfd + ", " + r.fd + ")" }); out.calls.push({ call: "close", fd: nfd, text: "close(" + nfd + ")" }); } } // A failure is reported on whatever fd 2 is at that moment, which may // already be a file this line opened, or nothing. if (out.error) { out.error.to = table[2] ? { desc: table[2], label: label(table[2]) } : null; } // The table as the program would inherit it, lowest descriptor first, // with the lowest descriptor sharing each open file description. var fds = Object.keys(table).map(Number).sort(function (a, b) { return a - b; }); var firstHolder = {}; out.fds = fds.map(function (fd) { var d = table[fd]; if (!(d.id in firstHolder)) firstHolder[d.id] = fd; return { fd: fd, desc: d, label: label(d), shares: firstHolder[d.id] }; }); // Stage 5. if (!out.error && out.argv.length) { out.exec = true; out.calls.push({ call: "execve", text: "execve(\"" + out.argv[0] + "\", [" + out.argv.map(function (a) { return JSON.stringify(a); }).join(", ") + "])" }); } result.members.push(out); }); // Members of a pipeline run at once. A file one of them reads and another // creates exists or not depending on which gets there first. reads.forEach(function (rd) { if (rd.name in created && created[rd.name] !== rd.member) { refuse(4, "\"" + rd.name + "\" is read by one command of this pipeline and " + "created by another. They run at the same time, so whether it exists " + "yet depends on which gets there first, and this page does not guess."); } }); // And a pattern in one member could match a file another one creates. patterns.forEach(function (pt) { Object.keys(created).forEach(function (name) { if (created[name] !== pt.member && matches(pt.g.toks, name)) { refuse(4, "\"" + pt.g.text + "\" would match \"" + name + "\", which another " + "command in this pipeline creates. They run at the same time, so whether " + "it is in the list depends on which gets there first, and this page does not guess."); } }); }); result.dir = Object.keys(effects).sort(byteOrder).map(function (k) { return { name: k, effect: effects[k] }; }); result.ok = true; } catch (e) { if (!(e instanceof Refusal)) throw e; result.refused = { stage: e.stage, reason: e.reason }; } return result; } /* ---- a second route to the table ---------------------------------------- * Replays a member's call list, as text, on a fresh table of three * terminals. It shares nothing with run() but the call text, so if run() * ever updates its table without printing the call that would do it, or * prints a call it did not make, the two disagree. */ function replay(calls) { var t = { 0: "terminal (the shell's fd 0)", 1: "terminal (the shell's fd 1)", 2: "terminal (the shell's fd 2)" }; calls.forEach(function (c) { var m; if ((m = /^dup2\(pipe (\d+) (read|write) end, (\d)\)$/.exec(c.text))) { t[m[3]] = "pipe " + m[1] + ", " + m[2] + " end"; } else if ((m = /^open\("(.*)", [A-Z_|]+\) = (\d+)$/.exec(c.text))) { t[m[2]] = m[1]; } else if ((m = /^dup2\((\d+), (\d+)\)$/.exec(c.text))) { if (m[1] in t) t[m[2]] = t[m[1]]; } else if ((m = /^close\((\d+)\)$/.exec(c.text))) { delete t[m[1]]; } }); return t; } /* ---- what real dash and bash did ------------------------------------------- * Recorded by scripts/shell-expansion-oracle.py on 2026-10-07: dash * 0.5.12-12ubuntu3 and GNU bash 5.3.9(1)-release on Linux 7.0.0-31-generic, * LC_ALL=C, in the directory above. Per line and shell: each executed * command's argv and descriptor table ("fd target shares"), the files that * were created or truncated, and the kinds of error the shell reported. The * test holds this table against the fixture the script wrote, so it cannot * drift from what the shells said. */ var RECORDED = { versions: { dash: "0.5.12-12ubuntu3", bash: "5.3.9(1)-release", kernel: "7.0.0-31-generic", date: "2026-10-07" }, cases: [ { line: "ls $g", shell: "dash", want: {"procs":[{"argv":["ls","a.txt","b.txt","my file.txt","a.txt","b.txt","my file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls $g", shell: "bash", want: {"procs":[{"argv":["ls","a.txt","b.txt","my file.txt","a.txt","b.txt","my file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls $g", shell: "posix", want: {"procs":[{"argv":["ls","a.txt","b.txt","my file.txt","a.txt","b.txt","my file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls \"$g\"", shell: "dash", want: {"procs":[{"argv":["ls","*.txt *.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls \"$g\"", shell: "bash", want: {"procs":[{"argv":["ls","*.txt *.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls \"$g\"", shell: "posix", want: {"procs":[{"argv":["ls","*.txt *.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls $f", shell: "dash", want: {"procs":[{"argv":["ls","my","file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls $f", shell: "bash", want: {"procs":[{"argv":["ls","my","file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls $f", shell: "posix", want: {"procs":[{"argv":["ls","my","file.txt"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls *", shell: "dash", want: {"procs":[{"argv":["ls","README","a.txt","b.txt","c.log","my file.txt","x.xyz","y.xyz"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls *", shell: "bash", want: {"procs":[{"argv":["ls","README","a.txt","b.txt","c.log","my file.txt","x.xyz","y.xyz"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls *", shell: "posix", want: {"procs":[{"argv":["ls","README","a.txt","b.txt","c.log","my file.txt","x.xyz","y.xyz"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls .*", shell: "dash", want: {"procs":[{"argv":["ls",".","..",".hidden.txt",".profile"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls .*", shell: "bash", want: {"procs":[{"argv":["ls",".hidden.txt",".profile"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls .*", shell: "posix", want: {"procs":[{"argv":["ls",".hidden.txt",".profile"],"fds":["0 inherited 0 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls > *.xyz", shell: "dash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file *.xyz 1","2 inherited 2 2"]}],"changed":["*.xyz"],"errors":[]} }, { line: "ls > *.xyz", shell: "bash", want: {"procs":[],"changed":[],"errors":["ambiguous"]} }, { line: "ls > *.xyz", shell: "posix", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file *.xyz 1","2 inherited 2 2"]}],"changed":["*.xyz"],"errors":[]} }, { line: "ls > R*", shell: "dash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file R* 1","2 inherited 2 2"]}],"changed":["R*"],"errors":[]} }, { line: "ls > R*", shell: "bash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file README 1","2 inherited 2 2"]}],"changed":["README"],"errors":[]} }, { line: "ls > R*", shell: "posix", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file R* 1","2 inherited 2 2"]}],"changed":["R*"],"errors":[]} }, { line: "ls >o 2>&1", shell: "dash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 file o 1"]}],"changed":["o"],"errors":[]} }, { line: "ls >o 2>&1", shell: "bash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 file o 1"]}],"changed":["o"],"errors":[]} }, { line: "ls >o 2>&1", shell: "posix", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 file o 1"]}],"changed":["o"],"errors":[]} }, { line: "ls 2>&1 >o", shell: "dash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 inherited 1 2"]}],"changed":["o"],"errors":[]} }, { line: "ls 2>&1 >o", shell: "bash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 inherited 1 2"]}],"changed":["o"],"errors":[]} }, { line: "ls 2>&1 >o", shell: "posix", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 file o 1","2 inherited 1 2"]}],"changed":["o"],"errors":[]} }, { line: "ls 2>&1 | wc", shell: "dash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 pipe 1 1","2 pipe 1 1"]},{"argv":["wc"],"fds":["0 pipe 1 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls 2>&1 | wc", shell: "bash", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 pipe 1 1","2 pipe 1 1"]},{"argv":["wc"],"fds":["0 pipe 1 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} }, { line: "ls 2>&1 | wc", shell: "posix", want: {"procs":[{"argv":["ls"],"fds":["0 inherited 0 0","1 pipe 1 1","2 pipe 1 1"]},{"argv":["wc"],"fds":["0 pipe 1 0","1 inherited 1 1","2 inherited 2 2"]}],"changed":[],"errors":[]} } ] }; /* The same summary the recording uses, computed from a result: what each * executed command was handed, which files were created, truncated or * written to by a complaint, and the kinds of complaint that reached the * terminal's fd 2, which are the only ones the recording can see. */ function summary(res) { var pipes = {}; function name(d) { if (d.kind === "tty") return "inherited " + d.origin; if (d.kind === "null") return "/dev/null"; if (d.kind === "pipe") { if (!(d.pipe in pipes)) pipes[d.pipe] = Object.keys(pipes).length + 1; return "pipe " + pipes[d.pipe]; } return "file " + d.name; } var procs = [], errors = [], changed = res.dir.map(function (d) { return d.name; }); res.members.forEach(function (m) { if (m.error && m.error.to) { var d = m.error.to.desc; if (d.kind === "tty" && d.origin === 2) errors.push(m.error.kind); if (d.kind === "file" && d.mode === "w" && changed.indexOf(d.name) < 0) changed.push(d.name); } if (!m.exec) return; procs.push({ argv: m.argv, fds: m.fds.map(function (f) { return f.fd + " " + name(f.desc) + " " + f.shares; }) }); }); return { procs: procs, changed: changed.sort(byteOrder), errors: errors.sort() }; } /* ---- the briefs: make one ------------------------------------------------- */ var BRIEFS = [ { id: "errors-to-log", ask: "Keep what ls prints on the terminal, and send what it complains about to a file called log.", check: function (res) { if (!res.ok) return "The page refused the line, so there is nothing to check."; if (res.members.length !== 1) return "This one needs a single command, not a pipeline."; var m = res.members[0]; if (m.argv[0] !== "ls") return "The command has to be ls."; if (m.error) return "The line fails before ls runs: " + m.error.text + "."; var fd = function (n) { return m.fds.filter(function (f) { return f.fd === n; })[0]; }; var one = fd(1), two = fd(2); if (!two || two.desc.kind !== "file" || two.desc.name !== "log") { return "Descriptor 2, where ls complains, is " + (two ? two.label : "closed") + ", not the file log."; } if (!one || one.desc.kind !== "tty" || one.desc.origin !== 1) { return "Descriptor 1, where ls prints, is " + (one ? one.label : "closed") + ", not the terminal."; } return null; } }, { id: "one-argument", ask: "Using the variable g, hand ls the eleven characters *.txt *.txt as one single argument.", check: function (res) { if (!res.ok) return "The page refused the line, so there is nothing to check."; if (res.members.length !== 1) return "This one needs a single command, not a pipeline."; var m = res.members[0]; if (m.argv[0] !== "ls") return "The command has to be ls."; var usesG = m.words.some(function (w) { return w.chars.some(function (ch) { return ch.from === "g"; }); }); if (!usesG) return "The line does not use $g."; var want = "*.txt *.txt"; var got = m.argv.slice(1); if (got.length !== 1) return "ls gets " + got.length + " arguments: " + got.join(" | ") + "."; if (got[0] !== want) return "ls gets one argument, but it is \"" + got[0] + "\"."; return null; } }, { id: "both-into-wc", ask: "Make wc read both what ls prints and what ls complains about.", check: function (res) { if (!res.ok) return "The page refused the line, so there is nothing to check."; if (res.members.length !== 2) return "This one needs two commands joined by a pipe."; var a = res.members[0], b = res.members[1]; if (a.argv[0] !== "ls" || b.argv[0] !== "wc") return "The first command has to be ls and the second wc."; var fd = function (m, n) { return m.fds.filter(function (f) { return f.fd === n; })[0]; }; var into = function (f) { return f && f.desc.kind === "pipe" && f.desc.end === "w"; }; if (!into(fd(a, 1))) return "ls prints to " + (fd(a, 1) ? fd(a, 1).label : "nothing") + ", not into the pipe."; if (!into(fd(a, 2))) return "ls complains to " + (fd(a, 2) ? fd(a, 2).label : "nothing") + ", not into the pipe."; var w0 = fd(b, 0); if (!w0 || w0.desc.kind !== "pipe") return "wc reads from " + (w0 ? w0.label : "nothing") + ", not the pipe."; return null; } } ]; function checkBrief(id, res) { var b = BRIEFS.filter(function (x) { return x.id === id; })[0]; if (!b) return { ok: false, said: "No such brief." }; var wrong = b.check(res); return { ok: wrong === null, said: wrong === null ? "That does it." : wrong }; } /* ---- what the page checks when it loads ----------------------------------- */ function argvOf(line, shellName, vars) { var r = run(line, vars || DEFAULT_VARS, shellName); return r.ok && r.members.length ? r.members[0].argv : null; } function fdOf(res, member, fd) { if (!res.ok || !res.members[member]) return null; return res.members[member].fds.filter(function (f) { return f.fd === fd; })[0] || null; } function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); } function verify() { var out = []; var V = DEFAULT_VARS; // Splitting before globbing: one value, two patterns, every match twice. var star = glob([{ c: "*", q: false }, { c: ".", q: false }, { c: "t", q: false }, { c: "x", q: false }, { c: "t", q: false }], SHELLS.dash).matched; var twice = argvOf("ls $g", "dash"); out.push({ claim: "$g holds \"" + V.g + "\", so splitting makes two patterns before globbing, and ls is " + "handed every .txt file twice", ok: star.length > 0 && !!twice && twice.length - 1 === 2 * star.length && same(twice.slice(1), star.concat(star)), untested: star.length === 0, detail: star.length === 0 ? "no file matches *.txt, so there is nothing to count" : (twice ? twice.length - 1 : 0) + " arguments, from " + star.length + " matches of *.txt" }); // The other order, computed: one pattern with a blank in it matches nothing. var other = run("ls $g", V, "dash", { otherOrder: true }); var otherArgv = other.ok ? ["ls"].concat(other.members[0].words[1].otherOrder) : null; out.push({ claim: "if globbing came first, the same line would pass ls two literal *.txt " + "arguments and name no file", ok: !!otherArgv && same(otherArgv, ["ls", "*.txt", "*.txt"]) && !same(otherArgv, twice), detail: otherArgv ? otherArgv.slice(1).join(", ") : "the line was refused" }); // Quoted, the value is one argument and no pattern. var quoted = argvOf("ls \"$g\"", "dash"); out.push({ claim: "quoted, \"$g\" reaches ls as one argument, stars and all", ok: same(quoted, ["ls", V.g]), detail: quoted ? quoted.length - 1 + " argument: " + quoted.slice(1).join(" | ") : "refused" }); // V7's own sentence: implicit nulls removed, explicit nulls retained. var nulls = argvOf("ls $e \"$e\" ''", "dash"); out.push({ claim: "an unquoted empty $e disappears and a quoted one stays, as the 1979 manual says", ok: same(nulls, ["ls", "", ""]), detail: nulls ? (nulls.length - 1) + " arguments from three words" : "refused" }); // The leading dot. var dots = FILES.filter(function (f) { return f.charAt(0) === "."; }); var all = argvOf("ls *", "dash"); var dashDots = argvOf("ls .*", "dash"), bashDots = argvOf("ls .*", "bash"); out.push({ claim: "* matches no name that starts with a dot, and .* reaches \".\" and \"..\" in " + "dash but not in bash", ok: dots.length > 0 && !!all && all.slice(1).every(function (f) { return f.charAt(0) !== "."; }) && !!dashDots && dashDots.indexOf("..") > 0 && !!bashDots && bashDots.indexOf("..") < 0 && bashDots.length - 1 === dots.length, untested: dots.length === 0, detail: dots.length === 0 ? "there is no dotfile to hide" : (all ? all.length - 1 : 0) + " names for *; .* gives " + (dashDots ? dashDots.length - 1 : 0) + " in dash and " + (bashDots ? bashDots.length - 1 : 0) + " in bash" }); // Byte order. var names = all ? all.slice(1) : []; var sorted = names.slice().sort(byteOrder); out.push({ claim: "the names come back in byte order, so README sorts before a.txt", ok: names.length > 1 && same(names, sorted) && names.indexOf("README") === 0, untested: names.length < 2, detail: names.length < 2 ? "fewer than two names to order" : names.slice(0, 3).join(", ") + ", ..." }); // The redirection target. var xyz = FILES.filter(function (f) { return /\.xyz$/.test(f); }); var tD = run("ls > *.xyz", V, "dash"), tB = run("ls > *.xyz", V, "bash"), tP = run("ls > *.xyz", V, "posix"); var made = function (res, name) { return res.ok && res.dir.some(function (d) { return d.name === name && d.effect === "created"; }); }; out.push({ claim: "with " + xyz.length + " files matching *.xyz, \"ls > *.xyz\" creates a file " + "literally named *.xyz in dash and in bash --posix, and bash refuses it", ok: xyz.length >= 2 && made(tD, "*.xyz") && made(tP, "*.xyz") && tB.ok && !!tB.members[0].error && tB.members[0].error.kind === "ambiguous" && !tB.members[0].exec, untested: xyz.length < 2, detail: xyz.length < 2 ? "the pattern needs two matches to be ambiguous" : "bash: " + (tB.ok && tB.members[0].error ? tB.members[0].error.text : "no error") }); var oneD = run("ls > R*", V, "dash"), oneB = run("ls > R*", V, "bash"); var rs = FILES.filter(function (f) { return f.charAt(0) === "R"; }); out.push({ claim: "when the pattern matches exactly one file, bash opens that file and empties it", ok: rs.length === 1 && oneB.ok && oneB.dir.length === 1 && oneB.dir[0].name === rs[0] && oneB.dir[0].effect === "truncated" && made(oneD, "R*"), untested: rs.length !== 1, detail: rs.length !== 1 ? "R* has to match exactly one name" : "bash truncates " + rs[0] + "; dash creates a file named R*" }); // dup2 order. var a = run("ls >o 2>&1", V, "dash"), b = run("ls 2>&1 >o", V, "dash"); var a1 = fdOf(a, 0, 1), a2 = fdOf(a, 0, 2), b1 = fdOf(b, 0, 1), b2 = fdOf(b, 0, 2); out.push({ claim: ">o 2>&1 leaves 1 and 2 sharing one open file, o; 2>&1 >o leaves 2 on the terminal", ok: !!(a1 && a2 && b1 && b2) && a1.desc.name === "o" && a2.shares === 1 && b1.desc.name === "o" && b2.desc.kind === "tty" && b2.desc.origin === 1, detail: a2 && b2 ? "fd 2 ends as " + a2.label + " and as " + b2.label : "a descriptor is missing" }); // The pipe is wired first. var p = run("ls 2>&1 | wc", V, "dash"); var p2 = fdOf(p, 0, 2), w0 = fdOf(p, 1, 0); out.push({ claim: "in ls 2>&1 | wc the pipe is connected before 2>&1, so ls's errors go into the pipe", ok: !!(p2 && w0) && p2.desc.kind === "pipe" && p2.desc.end === "w" && w0.desc.kind === "pipe", detail: p2 ? "ls fd 2 is " + p2.label : "no fd 2" }); // A second route to every table the presets produce. var replayed = 0, disagree = []; PRESETS.forEach(function (line) { Object.keys(SHELLS).forEach(function (s) { var r = run(line, V, s); if (!r.ok) return; r.members.forEach(function (m) { if (m.error) return; replayed += 1; var t = replay(m.calls), mine = {}; m.fds.forEach(function (f) { mine[f.fd] = f.label; }); if (!same(t, mine)) disagree.push(s + ": " + line); }); }); }); out.push({ claim: "replaying each printed list of calls on a fresh table gives the table shown, " + "for every preset in every shell", ok: replayed > 0 && disagree.length === 0, untested: replayed === 0, detail: replayed === 0 ? "nothing was replayed" : disagree.length ? disagree.length + " disagree, first " + disagree[0] : replayed + " tables, all equal" }); // What the real shells did. var held = 0, failed = []; RECORDED.cases.forEach(function (c) { var got = summary(run(c.line, V, c.shell)); if (same(got, c.want)) held += 1; else failed.push(c.shell + ": " + c.line); }); out.push({ claim: "the engine reproduces every outcome recorded from dash " + RECORDED.versions.dash + " and bash " + RECORDED.versions.bash + " for the lines on this page", ok: RECORDED.cases.length > 0 && failed.length === 0, untested: RECORDED.cases.length === 0, detail: RECORDED.cases.length === 0 ? "no recordings to compare" : failed.length ? failed.length + " differ, first " + failed[0] : held + " of " + RECORDED.cases.length + " recorded outcomes" }); return out; } /* The preset lines the page offers. */ var PRESETS = ["ls $g", "ls \"$g\"", "ls $f", "ls *", "ls .*", "ls > *.xyz", "ls > R*", "ls >o 2>&1", "ls 2>&1 >o", "ls 2>&1 | wc"]; window.ShellExpansion = { FILES: FILES, COMMANDS: COMMANDS, BUILTINS: BUILTINS, RESERVED: RESERVED, SHELLS: SHELLS, SET_AT_START: SET_AT_START, BLANKS: BLANKS, DEFAULT_VARS: DEFAULT_VARS, PRESETS: PRESETS, RECORDED: RECORDED, BRIEFS: BRIEFS, tokenize: tokenize, parse: parse, expand: expand, split: split, glob: glob, matches: matches, compile: compile, run: run, replay: replay, summary: summary, checkBrief: checkBrief, verify: verify }; })();