#!/usr/bin/env python3 """Record the red team's lines (2026-10-08) in real dash and bash. scripts/shell-expansion-oracle.py records the page's own sweep. This script reuses its probe and its run_line unchanged, so each run is summarised the same way, and records the lines a red team found the engine wrong on: character classes, the variables a shell sets for itself, a dup of a descriptor onto itself, bash's $'...' and a=~, and a few lines no earlier fixture had (a backslash inside double quotes, $!, a value holding a tab). Each line carries what the page must do with it: "agree" (accept it and match the shell) or a pattern its refusal reason must match, either for every shell or one per shell. The test reads both. It also asks each shell which of a list of names is set when it starts, in the same environment the lines run in, with ${NAME+set}. The engine refuses those names by shell and the test holds its list against this one. python3 tests/shell-expansion-extra-oracle.py writes tests/fixtures/shell-expansion-extra.json """ from __future__ import annotations import importlib.util import json import shutil import subprocess import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "tests" / "fixtures" / "shell-expansion-extra.json" spec = importlib.util.spec_from_file_location("oracle", ROOT / "scripts" / "shell-expansion-oracle.py") o = importlib.util.module_from_spec(spec) spec.loader.exec_module(o) # One value with a tab in it, so a blank other than space is exercised. o.VARS["h"] = "a.txt\tb.txt" CLASS = "inside brackets starts" SHELLVAR = "sets it for itself" BASHQ = r"\$'\.\.\.'" TILDE = "tilde" BASH_TILDE = {"dash": "agree", "bash": TILDE, "posix": "agree"} SPECIAL = "special or positional" CASES = [ # F1: classes, equivalence classes and collating symbols are refused ("ls [[:upper:]]*", CLASS), ("ls [[:alpha:]].txt", CLASS), ("ls [![:lower:]]*", CLASS), ("ls *[[:space:]]*", CLASS), ("ls [[=a=]].txt", CLASS), ("ls [[.a.]].txt", CLASS), ("ls $q", "agree"), # a [ inside brackets that starts none of them is still an ordinary member ("ls [[]*", "agree"), ("ls [a[]*", "agree"), ("ls [[a]*", "agree"), # F2: IFS has its default value; the other names a shell sets are refused ("ls x${IFS}y", "agree"), ('ls "$IFS"', "agree"), ("ls $IFS", "agree"), ("ls a$IFS", "agree"), ('ls "x$IFS"y', "agree"), ("ls x$IFS$f", "agree"), ("ls > x${IFS}y", "agree"), ('ls > "$IFS"', "agree"), ("ls $PWD", SHELLVAR), ('ls "$PPID"', SHELLVAR), ("ls $OPTIND", SHELLVAR), ("ls $PS4", SHELLVAR), ("ls $PATH", SHELLVAR), ("ls $LC_ALL", SHELLVAR), # F3: a dup of a descriptor onto itself is no dup at all, open or not ("ls 3>&3", "agree"), ("ls 3<&3", "agree"), ("ls 5>&5", "agree"), ("ls 9<&9", "agree"), ("ls 3>&3 3>o", "agree"), ("ls 3>&3 | wc", "agree"), ("ls | wc 4<&4", "agree"), ("ls 2>&- 2>&2", "agree"), ("ls >&- >&1", "agree"), ("ls <&- <&0", "agree"), ("ls 0<&- 0<&0", "agree"), ("ls 2>&2", "agree"), ("ls 2>o 2>&2", "agree"), # F4: bash's quoting forms ("ls $'a.txt'", BASHQ), ('ls $"a.txt"', BASHQ), ("ls $'*'", BASHQ), ("ls x$'a'", BASHQ), ('ls "$\'"', "agree"), # F5: bash's tilde after the = of a word shaped like an assignment, or after # a : in one. Refused in bash only; dash and bash --posix leave it alone. ("ls a=~", BASH_TILDE), ("ls x=a:~", BASH_TILDE), ("ls > a=~", BASH_TILDE), ("ls a:~", "agree"), ("ls a~b", "agree"), ("ls a=b", "agree"), ("ls 1a=~", "agree"), # F6: a dup followed straight away by a redirection ("ls 2>&1>o", "straight after"), # F7: what kept three mutants alive ('ls "\\a.txt"', "agree"), ('ls "a\\.txt"', "agree"), ('ls "my\\ file.txt"', "agree"), ('ls "\\$g"', "agree"), ('ls "a\\\\"', "agree"), ("ls $!", SPECIAL), ("ls $$", SPECIAL), ("ls $-", SPECIAL), ("ls $#", SPECIAL), ("ls $?", SPECIAL), ("ls $0", SPECIAL), ("ls $h", "agree"), ('ls "$h"', "agree"), ("ls $h$f", "agree"), ("ls > $h", "agree"), ] # Names that a shell might set for itself or carry in from the environment. # The answer for each comes from the shells; this list only says what to ask. CANDIDATES = sorted(set(""" IFS PWD OLDPWD PPID OPTIND OPTARG OPTERR PS1 PS2 PS3 PS4 HOME MAIL MAILPATH MAILCHECK ENV CDPATH LINENO RANDOM SRANDOM SECONDS EPOCHSECONDS EPOCHREALTIME HISTCMD HISTFILE HISTSIZE HISTFILESIZE HOSTNAME HOSTTYPE MACHTYPE OSTYPE TERM SHELL SHLVL UID EUID GROUPS DIRSTACK BASH BASHOPTS BASHPID BASH_ALIASES BASH_ARGC BASH_ARGV BASH_ARGV0 BASH_CMDS BASH_COMMAND BASH_EXECUTION_STRING BASH_LINENO BASH_LOADABLES_PATH BASH_MONOSECONDS BASH_SOURCE BASH_SUBSHELL BASH_VERSINFO BASH_VERSION COMP_WORDBREAKS SHELLOPTS POSIXLY_CORRECT FUNCNAME PIPESTATUS COLUMNS LINES PATH LC_ALL PROBE_OUT _ """.split())) def set_names(base: Path) -> dict: out = {} for shell, cmd in o.SHELLS.items(): script = "; ".join(f'[ -n "${{{n}+set}}" ] && echo {n}' for n in CANDIDATES) + "; :" env = {"PATH": "/nonexistent", "LC_ALL": "C", "PROBE_OUT": str(base / "x")} env.update(o.VARS) r = subprocess.run(cmd + [script], cwd=base, env=env, capture_output=True, text=True) out[shell] = sorted(r.stdout.split()) return out def main() -> int: base = Path(tempfile.mkdtemp(prefix="shell-expansion-extra-", dir=Path.home() / ".cache")) try: probe = o.build_probe(base) cases = [{"line": line, "page": page, "shells": {s: o.run_line(probe, base, line, s) for s in o.SHELLS}} for line, page in CASES] fixture = {"generated_by": "tests/shell-expansion-extra-oracle.py", "versions": o.versions(), "vars": o.VARS, "shells": list(o.SHELLS), "candidates": CANDIDATES, "set_at_start": set_names(base), "cases": cases} finally: shutil.rmtree(base, ignore_errors=True) text = json.dumps(fixture, indent=None, separators=(",", ":")).replace(',{"line":', ',\n{"line":') OUT.write_text(text + "\n") print(f"wrote {OUT}: {len(cases)} lines x {len(o.SHELLS)} shells") return 0 if __name__ == "__main__": raise SystemExit(main())