diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index d0a3998f..bc201aab 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -40,8 +40,41 @@ FIXTURES="$TMP/fixtures.tsv" printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n' printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n' printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n' + # The target may be HOME itself. End-of-command and whitespace terminate the + # token just as a slash does; punctuation that can extend a path does not. + printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n' + printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n' + printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n' + printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n' + # Unquoted POSIX metacharacters terminate the target word even without spaces. + printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x $HOMEout"}}\toutput redirection terminates the target word\n' + printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n' printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n' printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n' + printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n' + printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n' + printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n' printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n' printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n' # Quote removal must not create an expansion the shell never performs. @@ -485,6 +518,10 @@ home_case 'a usable HOME still allows a checkout onto a work filesystem' \ 0 '/home/tester' 'git clone https://example.invalid/x /src/wt' home_case 'a usable HOME still catches the literal path' \ 2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under' +home_case 'a usable HOME catches the exact literal path without a trailing slash' \ + 2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under' +home_case 'quotes around the exact literal HOME path do not change the target' \ + 2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under' home_case 'a quoted literal HOME segment remains contiguous with the suffix' \ 2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under' home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \ diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 9cd9d10e..570ec7b9 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -100,13 +100,23 @@ normalize_command_words() { -v protect_path_literals="$protect_path_literals" ' BEGIN { state = "outside"; out = ""; word_start = 1 + word_boundary = sprintf("%c", 27) literal_dollar = sprintf("%c", 28) literal_tilde = sprintf("%c", 29) } { - if (NR > 1) { out = out "\n"; word_start = 1 } + if (NR > 1) { + if (protect_path_literals) out = out word_boundary + else out = out "\n" + word_start = 1 + } for (i = 1; i <= length($0); i++) { c = substr($0, i, 1) + # A raw marker byte is ordinary word content. Encode it visibly so only + # this machine can manufacture an internal boundary marker. + if (protect_path_literals && c == word_boundary) { + out = out "\\x1b"; word_start = 0; continue + } if (state == "single") { if (c == "\047") { state = "outside" @@ -144,7 +154,10 @@ normalize_command_words() { } continue } - if (c == "\\") { + if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) { + out = out word_boundary + word_start = 1 + } else if (c == "\\") { if (i == length($0)) { out = out c } else { @@ -173,12 +186,14 @@ normalize_command_words() { CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)" # Paths need the same quote and escape handling, but not name-mode substitution # flattening: an expansion-capable `$HOME` spelling must remain visible to the -# checkout check. Shell-literal dollar/tilde characters (single-quoted, escaped, -# or a quoted tilde) become internal nonmatching markers; otherwise quote removal -# would create a HOME spelling the shell never expands. A path assembled from a -# different variable or command substitution is absent from the literal text and +# checkout check. Unquoted POSIX shell metacharacters become an internal word- +# boundary marker; quoted/escaped metacharacters remain content. Shell-literal +# dollar/tilde characters become separate nonmatching markers; otherwise quote +# removal would create a HOME spelling the shell never expands. A path assembled +# from a different variable or command substitution is absent from the literal text and # remains outside a text guard's visibility. CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)" +PATH_WORD_BOUNDARY=$'\033' # The one place the shape of a program NAME is written down. Every name consumer # below uses it, so the next fix to this class lands in a single location instead of @@ -307,8 +322,12 @@ derives the path and never consults \$HOME at all: EOF exit 2 fi - # Any argument that resolves under $HOME and is not under a work filesystem. - if printf '%s' "$CMD_PATHS" | grep -Eq "(^|[[:space:]=])($home_re)/"; then + # Any argument that resolves to $HOME itself or beneath it. The path-mode + # normalizer marks every unquoted POSIX shell word boundary, so the home token + # must be preceded by a boundary/start/assignment and followed by a boundary, + # slash (a descendant), or end. Arbitrary filename bytes remain content; this + # avoids over-blocking sibling names such as $HOME+bak or $HOME.bak. + if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then cat <