diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 645edb70..c27d74b6 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -3594,6 +3594,12 @@ _acq_provider_facts_python() { # value is re-validated after parsing: these values reach an sh -c probe and a # --secret binding, so a tampered facts.json must not be able to smuggle shell # syntax, a cross-host models URL, or an unsafe ENV name any further. +# The embedded Python forces LF on stdout: native Windows Python turns each +# newline into CRLF when stdout is a pipe, and the `read` loop would then keep +# the trailing carriage return in every value. That explanation lives here, not +# in the heredoc, because bash 3.2 scans a heredoc body inside a process +# substitution for quote and parenthesis balance, so no comment or string in the +# body may contain an apostrophe, a backtick, or an unbalanced parenthesis. acq_provider_facts_json_fields() { local file="${1:-}" python value provider_id="" key_env="" host="" models_url="" api_protocol="" count=0 [ -n "$file" ] || return 2 @@ -3617,6 +3623,9 @@ acq_provider_facts_json_fields() { import json, sys from urllib.parse import urlsplit +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(newline="\n") + try: with open(sys.argv[1], encoding="utf-8") as handle: facts = json.load(handle) @@ -4336,6 +4345,49 @@ acq_provider_input_discard() { _acq_provider_input_cleanup_generation "$root" "$generation" } +# True (0) on MSYS2/Git-Bash/Cygwin. Separate from _acq_provider_input_msys_ps +# (which also requires LOCALAPPDATA) so tests can override it. +_acq_provider_input_is_msys() { + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac +} + +# Run python3 so that no argv word is rewritten by the shell runtime. MSYS2 +# converts POSIX-looking arguments to native paths before a native program sees +# them, and that conversion RESOLVES an intermediate NTFS junction or symlink, +# so the path walk below would validate a path whose ancestor links were already +# followed. Path words are therefore passed literally and translated to the +# native form by _acq_provider_input_native_path instead. The two variables are +# inert on POSIX hosts. +_acq_provider_input_py() { + MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 python3 "$@" +} + +# Translate PATH to the native form the Windows path walk expects WITHOUT +# resolving it. Identity on POSIX hosts. On MSYS the leading component is mapped +# through cygpath (it names a drive or a mount such as /c or /tmp, never +# user-controlled content) and every remaining component is appended as a +# string, so a junction or symlink below the mount point reaches the walk +# intact. A drive-form path is passed through with separators normalized. +# Relative paths are returned unchanged and rejected by the walk. +_acq_provider_input_native_path() { + local p="$1" rest first head + _acq_provider_input_is_msys || { printf '%s' "$p"; return 0; } + case "$p" in + [A-Za-z]:[/\\]*) printf '%s' "${p//\\//}"; return 0 ;; + /*) ;; + *) printf '%s' "$p"; return 0 ;; + esac + rest=${p#/} + first=${rest%%/*} + if [ "$first" = "$rest" ]; then rest=""; else rest=${rest#*/}; fi + case "$first" in .|..) return 1 ;; esac + command -v cygpath >/dev/null 2>&1 || return 1 + head=$(cygpath -m "/$first" 2>/dev/null) || return 1 + [ -n "$head" ] || return 1 + case "$head" in */) ;; *) head="$head/" ;; esac + printf '%s%s' "$head" "$rest" +} + # Normalize the three macOS kernel-managed path aliases to their canonical # /private/{var,tmp,etc} spellings so O_NOFOLLOW traversal never sees a # symlink at the first path component. Also collapses harmless absolute @@ -4351,7 +4403,7 @@ acq_provider_input_discard() { # builds never set this variable; it is documented alongside ACQ_TEST_HOST_OS as # a test-seam-only override. _acq_provider_input_canonical_path() { - python3 - "$1" <<'PY' + _acq_provider_input_py - "$1" <<'PY' import os, re, sys path = sys.argv[1] # Collapse harmless absolute spelling variants before alias matching. @@ -4385,42 +4437,129 @@ PY # including every user-influenced one — still traverse with O_NOFOLLOW. # Linux is unaffected. _acq_provider_input_safe_mkdir() { - local _canon + local _canon _native _canon=$(_acq_provider_input_canonical_path "$1") || return 1 - python3 - "$_canon" <<'PY' + _native=$(_acq_provider_input_native_path "$_canon") || return 1 + _acq_provider_input_py - "$_native" <<'PY' import os, stat, sys path = sys.argv[1] if not os.path.isabs(path): raise SystemExit('provider-input path must be absolute') -fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) -try: - for part in (p for p in path.split('/') if p): +# ACQ_TEST_FORCE_PORTABLE_WALK (offline tests only) selects the portable +# lstat walk below on POSIX so its symlink handling is testable without Windows. +_force_walk = bool(os.environ.get('ACQ_TEST_FORCE_PORTABLE_WALK')) +if hasattr(os, 'O_DIRECTORY') and hasattr(os, 'O_NOFOLLOW') and not _force_walk: + # POSIX: openat-style traversal with O_NOFOLLOW so no component follows a + # symlink (the host-owned projection tree must not be redirected). + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in (p for p in path.split('/') if p): + try: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + except FileNotFoundError: + os.mkdir(part, 0o755, dir_fd=fd) + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + if not stat.S_ISDIR(os.fstat(child).st_mode): raise SystemExit('unsafe provider-input path') + os.close(fd); fd = child + finally: + os.close(fd) +else: + # Windows (native Python 3.12): no O_DIRECTORY/O_NOFOLLOW/dir_fd. The caller + # passes the native path built lexically by _acq_provider_input_native_path + # with MSYS argv conversion disabled (see _acq_provider_input_py), so the + # path seen here is the literal one, never one whose intermediate junction + # the shell already resolved. Walk component by component from the + # drive root, creating missing directories one at a time so no ancestor is + # followed before it is validated. Reject symlinks (S_IFLNK), junctions + # (IO_REPARSE_TAG_MOUNT_POINT), and every other reparse point at each step, + # so a writable ancestor cannot redirect projection creation outside the + # intended tree (os.path.islink alone silently misses junctions). + if not hasattr(os.path, 'splitdrive'): + raise SystemExit('unsafe provider-input path: reparse-point traversal unavailable') + def _fatal(): + raise SystemExit('unsafe provider-input path') + def _unsafe(st): + # st_reparse_tag != 0 covers os.path.isjunction() (mount points) plus + # every other reparse tag; S_IFLNK covers symlinks. + return stat.S_ISLNK(st.st_mode) or getattr(st, 'st_reparse_tag', 0) != 0 + drive, tail = os.path.splitdrive(path) + if not drive and not (_force_walk and path.startswith('/')): + _fatal() + cur = drive + os.sep + def _validate_dir(p, create): try: - child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + st = os.lstat(p) except FileNotFoundError: - os.mkdir(part, 0o755, dir_fd=fd) - child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) - if not stat.S_ISDIR(os.fstat(child).st_mode): raise SystemExit('unsafe provider-input path') - os.close(fd); fd = child -finally: - os.close(fd) + if not create: + _fatal() + try: + os.mkdir(p, 0o755) + except FileExistsError: + pass + st = os.lstat(p) + except OSError: + _fatal() + if _unsafe(st) or not stat.S_ISDIR(st.st_mode): + _fatal() + _validate_dir(cur, False) + for comp in tail.replace('\\', '/').split('/'): + if comp in ('', '.'): + continue + if comp == '..': + _fatal() + cur = os.path.join(cur, comp) + _validate_dir(cur, True) PY } _acq_provider_input_safe_dir() { - local _canon + local _canon _native _canon=$(_acq_provider_input_canonical_path "$1") || return 1 - python3 - "$_canon" <<'PY' + _native=$(_acq_provider_input_native_path "$_canon") || return 1 + _acq_provider_input_py - "$_native" <<'PY' import os, stat, sys path = sys.argv[1] if not os.path.isabs(path): raise SystemExit(1) -fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) -try: - for part in (p for p in path.split('/') if p): - child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) - if not stat.S_ISDIR(os.fstat(child).st_mode): raise SystemExit(1) - os.close(fd); fd = child -finally: - os.close(fd) +_force_walk = bool(os.environ.get('ACQ_TEST_FORCE_PORTABLE_WALK')) +if hasattr(os, 'O_DIRECTORY') and hasattr(os, 'O_NOFOLLOW') and not _force_walk: + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in (p for p in path.split('/') if p): + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + if not stat.S_ISDIR(os.fstat(child).st_mode): raise SystemExit(1) + os.close(fd); fd = child + finally: + os.close(fd) +else: + # Windows (native Python 3.12): walk component by component from the drive + # root, rejecting symlinks (S_IFLNK), junctions (IO_REPARSE_TAG_MOUNT_POINT), + # and every other reparse point at each step so an attacker-controlled + # ancestor cannot redirect the existence/type check (islink alone misses + # junctions). Requires a real directory, never creating anything. + if not hasattr(os.path, 'splitdrive'): + raise SystemExit(1) + def _fatal(): + raise SystemExit(1) + def _unsafe(st): + return stat.S_ISLNK(st.st_mode) or getattr(st, 'st_reparse_tag', 0) != 0 + drive, tail = os.path.splitdrive(path) + if not drive and not (_force_walk and path.startswith('/')): + _fatal() + cur = drive + os.sep + def _validate_dir(p): + try: + st = os.lstat(p) + except OSError: + _fatal() + if _unsafe(st) or not stat.S_ISDIR(st.st_mode): + _fatal() + _validate_dir(cur) + for comp in tail.replace('\\', '/').split('/'): + if comp in ('', '.'): + continue + if comp == '..': + _fatal() + cur = os.path.join(cur, comp) + _validate_dir(cur) PY } @@ -4448,14 +4587,67 @@ _acq_provider_input_remove_tree() { } _acq_provider_input_atomic_replace() { - python3 - "$1" "$2" <<'PY' + local _src _dst + _src=$(_acq_provider_input_native_path "$1") || return 1 + _dst=$(_acq_provider_input_native_path "$2") || return 1 + _acq_provider_input_py - "$_src" "$_dst" <<'PY' import os, sys os.replace(sys.argv[1], sys.argv[2]) PY } +# Create a Windows junction LINK -> TARGET via `mklink /J`. Unlike a directory +# symlink (which needs elevation or Developer Mode), a junction needs neither, +# so it is the reliable reparse pointer on a stock Windows host. `cmd` is driven +# with MSYS argument escaping and backslash (`cygpath -w`) paths, matching the +# test helper in 147-provider-input-projection.bats. Returns non-zero on failure. +_acq_provider_input_make_junction() { + local link="$1" target="$2" win_link win_target + command -v cygpath >/dev/null 2>&1 || return 1 + win_link=$(cygpath -w "$link" 2>/dev/null) || return 1 + win_target=$(cygpath -w "$target" 2>/dev/null) || return 1 + cmd //c mklink //J "$win_link" "$win_target" >/dev/null 2>&1 +} + +# Remove a reparse-point link (POSIX symlink or Windows junction) WITHOUT +# following it into its target. MSYS rm/rmdir mishandle junctions, so use +# Python: `os.rmdir` on a junction issues RemoveDirectory on the reparse point +# itself (the target is left untouched). A symlink or plain file is unlinked. +_acq_provider_input_remove_link() { + local _native + _native=$(_acq_provider_input_native_path "$1") || return 1 + _acq_provider_input_py - "$_native" <<'PY' +import os, stat, sys +p = sys.argv[1] +try: + st = os.lstat(p) +except FileNotFoundError: + raise SystemExit(0) +if stat.S_ISDIR(st.st_mode): + os.rmdir(p) +else: + os.unlink(p) +PY +} + +# Move a reparse-point link (POSIX symlink or Windows junction) from SRC to DST +# WITHOUT following it into its target. A missing SRC is a no-op. os.rename moves +# the link itself (it does not resolve a junction), and DST must not exist yet. +_acq_provider_input_move_link() { + local _src _dst + _src=$(_acq_provider_input_native_path "$1") || return 1 + _dst=$(_acq_provider_input_native_path "$2") || return 1 + _acq_provider_input_py - "$_src" "$_dst" <<'PY' +import os, sys +try: + os.rename(sys.argv[1], sys.argv[2]) +except FileNotFoundError: + pass +PY +} + _acq_provider_input_publish() { - local root="$1" generation="$2" base temp current + local root="$1" generation="$2" base temp current backup case "$root" in ""|/|/*/) return 1 ;; esac case "$generation" in "$root"/generations/generation.*) ;; *) return 1 ;; esac _acq_provider_input_safe_dir "$root/generations" || return 1 @@ -4464,9 +4656,27 @@ _acq_provider_input_publish() { base=${generation##*/} temp="$root/.current.$$.$RANDOM" current="$root/current" - ln -s "generations/$base" "$temp" || return 1 - # rename is atomic within root; do not remove current first. - _acq_provider_input_atomic_replace "$temp" "$current" || { rm -f "$temp"; return 1; } + if _acq_provider_input_is_msys; then + # A directory symlink needs elevation/Developer Mode on Windows; a junction + # does not. Build the junction at `temp`, then swap it into place. Windows + # cannot atomically replace an existing junction (os.replace fails with + # Access denied), so move the old pointer aside, swap the new one in, and + # restore the old pointer if the swap fails — `current` is never left absent. + _acq_provider_input_make_junction "$temp" "$generation" || return 1 + backup="$current.acq-old.$$.$RANDOM" + _acq_provider_input_move_link "$current" "$backup" || { _acq_provider_input_remove_link "$temp"; return 1; } + if _acq_provider_input_atomic_replace "$temp" "$current"; then + _acq_provider_input_remove_link "$backup" + else + _acq_provider_input_move_link "$backup" "$current" + _acq_provider_input_remove_link "$temp" + return 1 + fi + else + ln -s "generations/$base" "$temp" || return 1 + # rename is atomic within root; do not remove current first. + _acq_provider_input_atomic_replace "$temp" "$current" || { rm -f "$temp"; return 1; } + fi # Retain prior generations. A sandbox created by an older acq may still mount # current, and a failed concurrent create must never make that sandbox lose its # inputs. New sandboxes mount an exact generation and are unaffected by swaps. @@ -4514,6 +4724,26 @@ acq_provider_input_lock_acquire() { printf '%s\n' "$lock" } +# True (0) on MSYS2/Git-Bash (MINGW/MSYS/CYGWIN), where `ps` does not accept +# the `-o FIELD=` output-format specifier and instead prints a fixed table via +# `ps -p PID` (columns: PID PPID PGID WINPID TTY UID STIME COMMAND). +_acq_provider_input_msys_ps() { + [ -n "${LOCALAPPDATA:-}" ] || return 1 + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac +} + +# Parent PID of PID (empty on failure), portable across GNU/macOS and MSYS. +# GNU/macOS: `ps -o ppid= -p PID`. MSYS: PPID is column 2 of `ps -p PID`. +_acq_provider_input_parent_pid() { + local pid="${1:-}" + case "$pid" in ""|*[!0-9]*) return 1 ;; esac + if _acq_provider_input_msys_ps; then + ps -p "$pid" 2>/dev/null | awk 'NR==2 {print $2}' + else + ps -o ppid= -p "$pid" 2>/dev/null + fi | tr -d '[:space:]' +} + # Start a real process whose lifetime is tied to the transaction subshell. Bash # 3.2 preserves $$ across subshells and has no BASHPID, so the lock records this # guardian PID instead. The guardian exits if its transaction parent disappears, @@ -4539,7 +4769,7 @@ _acq_provider_input_guardian_start() { done ' sh "$_ACQ_PROVIDER_INPUT_GUARDIAN_CHILDREN" & _ACQ_PROVIDER_INPUT_GUARDIAN_PID=$! - _ACQ_PROVIDER_INPUT_TRANSACTION_PID=$(ps -o ppid= -p "$_ACQ_PROVIDER_INPUT_GUARDIAN_PID" 2>/dev/null | tr -d '[:space:]') + _ACQ_PROVIDER_INPUT_TRANSACTION_PID=$(_acq_provider_input_parent_pid "$_ACQ_PROVIDER_INPUT_GUARDIAN_PID") case "$_ACQ_PROVIDER_INPUT_TRANSACTION_PID" in ""|*[!0-9]*) _acq_provider_input_guardian_stop; return 1 ;; esac export _ACQ_PROVIDER_INPUT_GUARDIAN_PID export _ACQ_PROVIDER_INPUT_TRANSACTION_PID @@ -4642,7 +4872,13 @@ _acq_provider_input_signal_exit() { _acq_provider_input_process_identity() { local pid="${1:-}" case "$pid" in ""|*[!0-9]*) return 1 ;; esac - ps -o lstart= -p "$pid" 2>/dev/null | tr -d '[:space:]' | grep . + if _acq_provider_input_msys_ps; then + # MSYS has no lstart; WINPID (column 4) is the stable, unique Windows + # process id, so it distinguishes a reused MSYS PID from the original owner. + ps -p "$pid" 2>/dev/null | awk 'NR==2 {print $4}' + else + ps -o lstart= -p "$pid" 2>/dev/null + fi | tr -d '[:space:]' | grep . } acq_provider_input_lock_release() { @@ -4692,53 +4928,114 @@ _acq_provider_input_control_plane() { # reads. Validation and later materialization therefore cannot observe a changed # candidate manifest, symlink, or path outside the fetched kit. _acq_provider_input_snapshot() { - python3 - "$1" "$2" <<'PY' + local _kit _target + _kit=$(_acq_provider_input_native_path "$1") || return 1 + _target=$(_acq_provider_input_native_path "$2") || return 1 + _acq_provider_input_py - "$_kit" "$_target" <<'PY' import json, os, stat, sys kit, target = sys.argv[1:] -def open_relative(root, value): - if not isinstance(value, str) or value.startswith('/') or not value or any(part in ('', '.', '..') for part in value.split('/')): +# ACQ_TEST_FORCE_PORTABLE_WALK (offline tests only) selects the portable lstat +# walk on POSIX so its symlink rejection is testable without a Windows runner. +_posix_nofollow = hasattr(os, 'O_DIRECTORY') and hasattr(os, 'O_NOFOLLOW') \ + and not os.environ.get('ACQ_TEST_FORCE_PORTABLE_WALK') +def rel_parts(rel): + # Single validation point for every manifest-declared path, used for both the + # source open and the destination join so the two can never disagree. Where + # the portable walk applies, backslash and drive/stream ':' are also rejected + # so a manifest cannot smuggle a separator or a drive-relative component. + if not isinstance(rel, str) or not rel or rel.startswith('/'): raise SystemExit('unsafe provider artifact path') - fd = os.dup(root) - try: - for part in value.split('/'): - nextfd = os.open(part, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) - os.close(fd); fd = nextfd - if not stat.S_ISREG(os.fstat(fd).st_mode): raise SystemExit('unsafe provider artifact') - return fd - except: - os.close(fd); raise -root = os.open(kit, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + if not _posix_nofollow: + if '\\' in rel or ':' in rel: + raise SystemExit('unsafe provider artifact path') + parts = rel.split('/') + if any(part in ('', '.', '..') for part in parts): + raise SystemExit('unsafe provider artifact path') + return parts +def safe_open(base, rel): + # Validate `rel` under `base` with a no-follow walk and return an OPEN + # binary reader bound to the validated file. On POSIX the reader keeps the + # descriptor from the O_NOFOLLOW walk, so a file or ancestor swapped after + # validation cannot redirect the read. On Windows (no O_NOFOLLOW/dir_fd) we + # walk with lstat and reopen the validated pathname as a guarded fallback. + parts = rel_parts(rel) + if _posix_nofollow: + fd = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for part in parts: + nextfd = os.open(part, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd); fd = nextfd + if not stat.S_ISREG(os.fstat(fd).st_mode): raise SystemExit('unsafe provider artifact') + return os.fdopen(fd, 'rb') + except BaseException: + os.close(fd) + raise + # Windows guarded fallback (native Python 3.12): no O_NOFOLLOW/dir_fd. Walk + # the relative path component by component under `base`, rejecting symlinks + # (S_IFLNK), junctions, and every other reparse point at each step, and + # require a regular file at the end. rel_parts() has already rejected + # backslash and ':' so a manifest path cannot smuggle a separator or a + # drive-relative component. This path is check-then-open by name (the stdlib + # has no handle-relative open on Windows), so it is weaker than the POSIX + # descriptor-bound read; it relies on the fetched kit living in a private + # host-owned staging directory. + def _fatal(): + raise SystemExit('unsafe provider artifact') + def _unsafe(st): + return stat.S_ISLNK(st.st_mode) or getattr(st, 'st_reparse_tag', 0) != 0 + cur = base + for i, part in enumerate(parts): + cur = os.path.join(cur, part) + try: + st = os.lstat(cur) + except OSError: + _fatal() + if _unsafe(st): + _fatal() + if i == len(parts) - 1: + if not stat.S_ISREG(st.st_mode): + _fatal() + elif not stat.S_ISDIR(st.st_mode): + _fatal() + return open(cur, 'rb') +if os.path.islink(kit) or not os.path.isdir(kit) \ + or getattr(os.lstat(kit), 'st_reparse_tag', 0) != 0: + raise SystemExit('unsafe provider kit path') +with safe_open(kit, 'provider-input.json') as stream: + manifest_bytes = stream.read() try: - manifest_fd = open_relative(root, 'provider-input.json') - with os.fdopen(manifest_fd, encoding='utf-8') as stream: manifest_bytes = stream.read() - try: - bundle = json.loads(manifest_bytes) - except json.JSONDecodeError as exc: - raise SystemExit( - 'acq: invalid provider input manifest: provider-input.json: ' - f'invalid JSON at line {exc.lineno} column {exc.colno}' - ) - os.mkdir(target, 0o700) - with open(os.path.join(target, 'provider-input.json'), 'x', encoding='utf-8') as stream: stream.write(manifest_bytes) - for path in ('spec.yaml', bundle['factsPath'], bundle['normalizerPath'], bundle['snapshotPath']): - source = open_relative(root, path) - destination = os.path.join(target, path) + bundle = json.loads(manifest_bytes) +except json.JSONDecodeError as exc: + raise SystemExit( + 'acq: invalid provider input manifest: provider-input.json: ' + f'invalid JSON at line {exc.lineno} column {exc.colno}' + ) +os.mkdir(target, 0o700) +with open(os.path.join(target, 'provider-input.json'), 'xb') as stream: + stream.write(manifest_bytes) +for path in ('spec.yaml', bundle['factsPath'], bundle['normalizerPath'], bundle['snapshotPath']): + # Validate and open the source BEFORE touching the destination tree, so a + # rejected path can never create a directory outside the candidate target. + with safe_open(kit, path) as src: + destination = os.path.join(target, *rel_parts(path)) os.makedirs(os.path.dirname(destination), mode=0o700, exist_ok=True) - with os.fdopen(source, 'rb') as src, open(destination, 'xb') as dst: - source_mode = stat.S_IMODE(os.fstat(src.fileno()).st_mode) + source_mode = stat.S_IMODE(os.fstat(src.fileno()).st_mode) + with open(destination, 'xb') as dst: while True: chunk = src.read(1024 * 1024) if not chunk: break dst.write(chunk) - os.fchmod(dst.fileno(), source_mode) -finally: - os.close(root) + os.chmod(destination, source_mode) PY } # Copy only validator-approved regular files into the backend-owned readonly tree. _acq_provider_input_materialize() { - python3 - "$1" "$2" "$3" <<'PY' + local _manifest _kit _root + _manifest=$(_acq_provider_input_native_path "$1") || return 1 + _kit=$(_acq_provider_input_native_path "$2") || return 1 + _root=$(_acq_provider_input_native_path "$3") || return 1 + _acq_provider_input_py - "$_manifest" "$_kit" "$_root" <<'PY' import json, os, shutil, sys manifest, kit, target_root = sys.argv[1:] bundle = json.load(open(manifest, encoding="utf-8")) @@ -4755,7 +5052,8 @@ for source, destination, mode in ((bundle["factsPath"], "facts.json", 0o444), (b if not isinstance(source, str) or source.startswith('/') or any(part in ('', '.', '..') for part in source.split('/')): raise SystemExit("unsafe provider artifact path") source = os.path.join(kit, source) - if os.path.islink(source) or not os.path.isfile(source): + if os.path.islink(source) or not os.path.isfile(source) \ + or getattr(os.lstat(source), 'st_reparse_tag', 0) != 0: raise SystemExit("unsafe provider artifact") destination = os.path.join(target, destination) shutil.copyfile(source, destination) diff --git a/acq.backends/kit-translate.sh b/acq.backends/kit-translate.sh index d28374ad..ad225068 100644 --- a/acq.backends/kit-translate.sh +++ b/acq.backends/kit-translate.sh @@ -166,19 +166,31 @@ kit_translate_fetch() { # url..insteadOf rewrite — `gh auth login` authenticates the gh CLI, # not plain git. See docs/KNOWN_FAILURE_MODES.md. # - # Attempt 1 (anonymous): prompts disabled + inherited credential helper and - # github.com insteadOf rewrite neutralized, so an unauthenticated fetch - # proceeds without prompting. + # Attempt 1 (anonymous): prompts disabled + inherited (system/global) git + # config isolated, so no url..insteadOf rewrite (e.g. an enterprise + # mirror) or credential helper can redirect or authenticate the fetch. # Attempt 2 (authed retry): if attempt 1 genuinely fails (a source that # needs auth / egress-restricted enterprise mirror that needs the rewrite), # retry with the system git config intact but STILL prompt-disabled, so a # configured credential helper can supply creds without ever hanging. _kit_git_fetch() { # $1 = extra git -c flags (as a single string, word-split intentionally) - local _cfg="$1" + # $2 = non-empty to isolate git from inherited (system + global) config. + local _cfg="$1" _isolate="${2:-}" ( cd "$destdir" || exit 1 export GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo GCM_INTERACTIVE=never + if [ -n "$_isolate" ]; then + # A command-line identity insteadOf rewrite does NOT shadow an + # inherited equal-length url.*.insteadOf (git's longest-prefix match + # keeps the inherited rewrite winning), and an empty insteadOf value + # makes git prepend the URL to itself. Isolate git from system and + # global config so only the fresh repo's .git/config applies; the + # authed retry below restores inherited config when it is required. + export GIT_CONFIG_NOSYSTEM=1 + export GIT_CONFIG_GLOBAL="$destdir/.gitconfig-empty" + : > "$GIT_CONFIG_GLOBAL" + fi rm -rf .git 2>/dev/null git init -q git remote add origin "$url" 2>/dev/null || git remote set-url origin "$url" @@ -195,8 +207,10 @@ kit_translate_fetch() { ) } local _ferr _anon_cfg - _anon_cfg="-c credential.helper= -c url.https://github.com/.insteadOf=" - if _ferr=$(_kit_git_fetch "$_anon_cfg"); then + # Attempt 1 runs with inherited git config isolated (see _kit_git_fetch); + # the credential helper is also explicitly emptied for defense-in-depth. + _anon_cfg="-c credential.helper=" + if _ferr=$(_kit_git_fetch "$_anon_cfg" isolate); then : elif _ferr=$(_kit_git_fetch ""); then # authed retry (system config) succeeded diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index f4f0b0e6..e0a29f7e 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1292,8 +1292,11 @@ _acq_msb_fetch_kit() { git+*|*://*) printf '%s\n' "$ACQ_MSB_KIT_LOCAL_DIR"; return 0 ;; esac fi - # Derive a stable cache subdir from the ref (sha + dir tail). - slug=$(printf '%s' "$kitref" | tr -c 'A-Za-z0-9._-' '_' ) + # Derive a stable, SHORT cache subdir from the ref. The full sanitized ref + # (URL + sha + dir) can exceed Windows MAX_PATH once git materializes + # .git/objects/pack/* under it ("Filename too long" on fetch), so key the + # cache by a hash of the ref instead — deterministic and collision-resistant. + slug=$(printf '%s' "$kitref" | git hash-object --stdin) dest="${ACQ_MSB_KIT_CACHE}/${slug}" if [ -d "$dest/.git" ]; then # Already fetched — recompute the kit subdir path from the ref's dir=. diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 7a27a5f7..06b60ff3 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -401,6 +401,26 @@ otherwise corrupt colon-delimited mounts and guest-only paths. See [ADR-0029](adr/0029-windows-host-guest-path-forms.md). On macOS/Linux the two forms are identical. +**Windows provider-input boundary:** on macOS/Linux the host-owned provider-input +tree and the fetched kit are walked with descriptor-relative, no-follow opens, and +the snapshot reads the very descriptor it validated. Native Windows Python has no +equivalent, so `acq` falls back to a component-by-component walk that rejects +symlinks, junctions, and any other reparse point at every level (creating missing +directories one level at a time), and rejects backslash and drive-qualified +manifest paths. Git Bash would otherwise convert a POSIX-looking argument to a +native path before Python sees it, and that conversion resolves an intermediate +junction or symlink, so the walk would never observe it. `acq` therefore runs +these helpers with argument conversion disabled and translates the path to its +native form lexically (only the leading drive or mount component goes through +`cygpath`; everything below it is appended as a string), so the walk checks the +literal path the caller passed. That fallback validates by name and then opens by +name, so it is weaker than the POSIX path: it assumes the staging and projection directories are +private to the current user. It is exercised on a real Windows runner only by the +Windows-guarded tests; the Linux suite covers the same walk's symlink handling by +forcing the portable code path and simulates the argument conversion with a shim. +That real Git Bash honors the conversion opt-out for this call can only be +confirmed on Windows. + ### Disposable primary (`--clone`) msb has no native clone mode, so the neutral `--clone` diff --git a/test/bats/100-kit-translate.bats b/test/bats/100-kit-translate.bats index dd780bc6..3eeda73f 100644 --- a/test/bats/100-kit-translate.bats +++ b/test/bats/100-kit-translate.bats @@ -226,11 +226,11 @@ SPEC refute_regex "$spec" "chmod 0755 '/workspace/w\.sh'" } -@test "#207: a git+https kit fetch is non-interactive and neutralizes credentials" { +@test "#207: a git+https kit fetch is non-interactive and isolates inherited git config" { local gitlog="$STUBDIR/git-invocations.log"; : >"$gitlog" cat >"$STUBDIR/git" <>"$gitlog" +printf 'PROMPT=%s NOSYS=%s GLOBAL=%s ARGS=%s\n' "\${GIT_TERMINAL_PROMPT:-UNSET}" "\${GIT_CONFIG_NOSYSTEM:-UNSET}" "\${GIT_CONFIG_GLOBAL:-UNSET}" "\$*" >>"$gitlog" case "\$*" in *fetch*) exit 0 ;; *checkout*) mkdir -p "integrations/isolation/acq-kits/usai-provider"; exit 0 ;; @@ -247,7 +247,42 @@ GITSTUB assert_regex "$fetchline" 'PROMPT=0' anonline=$(grep -m1 'credential.helper=' "$gitlog" || true) assert_regex "$anonline" 'credential\.helper=' - assert_regex "$anonline" 'insteadOf=' + assert_regex "$anonline" 'NOSYS=1' + assert_regex "$anonline" 'GLOBAL=.*\.gitconfig-empty' +} + +@test "#207: an equal-length global insteadOf mirror rewrite is neutralized for the anonymous attempt" { + # A global `url.https://mirror.invalid/.insteadOf=https://github.com/` has the + # SAME prefix length as the old command-line identity rewrite, so git's + # longest-prefix match kept the inherited mirror rewrite winning and the + # anonymous fetch was redirected to the mirror. Isolating git from inherited + # config (GIT_CONFIG_NOSYSTEM + empty GIT_CONFIG_GLOBAL) neutralizes it. + local realgit="$ACQ_TEST_REAL_GIT" home="$STUBDIR/fakehome" resolved="$STUBDIR/resolved-url" + mkdir -p "$home" + "$realgit" config --file "$home/.gitconfig" url.https://mirror.invalid/.insteadOf https://github.com/ + + # A git that resolves the origin URL (as a fetch would) with the REAL git under + # the fetch's own environment, records the result, and fakes a successful fetch. + cat >"$STUBDIR/git" < "$resolved"; exit 0 ;; + checkout) exit 0 ;; + *) "$realgit" "\${original[@]}" ;; +esac +GITSTUB + chmod +x "$STUBDIR/git" + + run bash -c ' + export HOME="'"$home"'" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + kit_translate_fetch "git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=integrations/isolation/acq-kits/usai-provider" "'"$STUBDIR"'/kitdest" >/dev/null 2>&1 + ' + + assert_success + assert_equal "$(cat "$resolved" 2>/dev/null || printf 'UNRESOLVED')" "https://github.com/GSA-TTS/agentic-coding-patterns.git" } @test "#207: authenticated retry diagnostics never expose credential-bearing URLs" { diff --git a/test/bats/147-provider-input-projection.bats b/test/bats/147-provider-input-projection.bats index f74f3ff9..b61b2036 100644 --- a/test/bats/147-provider-input-projection.bats +++ b/test/bats/147-provider-input-projection.bats @@ -1053,6 +1053,245 @@ EOF assert_failure } +# --------------------------------------------------------------------------- +# Windows reparse-point negative tests (real Windows runner only) +# --------------------------------------------------------------------------- +# The Windows fallback (native Python 3.12, no O_NOFOLLOW/dir_fd) walks each +# path component from the drive root and rejects symlinks (S_IFLNK), junctions +# (IO_REPARSE_TAG_MOUNT_POINT), and every other reparse point. os.path.islink() +# alone does NOT report junctions, so these tests prove the traversal actually +# rejects a planted junction / symlink ancestor rather than following it. +# +# They require a real Windows runner with a native Windows python3 on PATH (the +# repo CI is Linux, so they `skip` there). Junctions need no elevation; a +# directory symlink needs developer mode / admin, so the symlink cases skip +# when mklink /D is unavailable while the junction cases still run. + +_windows_runner() { + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac +} + +_windows_path() { command -v cygpath >/dev/null 2>&1 || return 1; cygpath -w "$1"; } + +_windows_make_junction() { # LINK TARGET (no elevation required) + local win_link win_target + win_link=$(_windows_path "$1") || return 1 + win_target=$(_windows_path "$2") || return 1 + cmd //c mklink //J "$win_link" "$win_target" >/dev/null 2>&1 +} + +_windows_make_dir_symlink() { # LINK TARGET (developer mode / admin required) + local win_link win_target + win_link=$(_windows_path "$1") || return 1 + win_target=$(_windows_path "$2") || return 1 + cmd //c mklink //D "$win_link" "$win_target" >/dev/null 2>&1 +} + +@test "windows: safe_mkdir succeeds on a real path (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local real="$STUBDIR/real_win/a/b" + run _acq_provider_input_safe_mkdir "$real" + assert_success + [ -d "$real" ] +} + +@test "windows: safe_mkdir rejects an ancestor junction (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local target="$STUBDIR/junction_target" link="$STUBDIR/junction_link" + mkdir -p "$target" + _windows_make_junction "$link" "$target" || skip "junction creation unavailable on this runner" + [ -d "$link" ] || skip "junction not created" + run _acq_provider_input_safe_mkdir "$link/new_child" + assert_failure + # The projection must not have been redirected into the junction target. + [ ! -e "$target/new_child" ] +} + +@test "windows: safe_dir rejects an ancestor junction (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local target="$STUBDIR/junction_target_dir" link="$STUBDIR/junction_link_dir" + mkdir -p "$target" + _windows_make_junction "$link" "$target" || skip "junction creation unavailable on this runner" + [ -d "$link" ] || skip "junction not created" + run _acq_provider_input_safe_dir "$link" + assert_failure +} + +@test "windows: safe_mkdir rejects an ancestor symlink (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local target="$STUBDIR/symlink_target" link="$STUBDIR/symlink_link" + mkdir -p "$target" + _windows_make_dir_symlink "$link" "$target" || skip "directory symlink creation unavailable (developer mode / elevation required)" + run _acq_provider_input_safe_mkdir "$link/new_child" + assert_failure + [ ! -e "$target/new_child" ] +} + +@test "windows: safe_dir rejects an ancestor symlink (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local target="$STUBDIR/symlink_target_dir" link="$STUBDIR/symlink_link_dir" + mkdir -p "$target" + _windows_make_dir_symlink "$link" "$target" || skip "directory symlink creation unavailable (developer mode / elevation required)" + run _acq_provider_input_safe_dir "$link" + assert_failure +} + +@test "windows: publication makes current a junction and retains generations (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local root old new native_current + root=$(acq_provider_input_root sbx winpub) + mkdir -p "$root/generations/generation.old/models/providers" "$root/generations/generation.new/models/providers" + old="$root/generations/generation.old"; new="$root/generations/generation.new" + + # Publish: current must become a junction (a reparse point), not a symlink or a + # directory copy, and it must resolve to the published generation. + run _acq_provider_input_publish "$root" "$new" + assert_success + native_current=$(cygpath -m "$root/current" 2>/dev/null) || skip "cygpath unavailable" + run _acq_provider_input_py -c 'import os,sys; print("junction" if os.path.isjunction(sys.argv[1]) else "not-junction")' "$native_current" + assert_success + assert_output "junction" + [ -d "$root/current/models/providers" ] + [ -d "$new" ] + + # Publish again: current swaps to the other generation; both stay intact. + run _acq_provider_input_publish "$root" "$old" + assert_success + [ -d "$root/current/models/providers" ] + [ -d "$old" ] + [ -d "$new" ] +} + +@test "windows: failed swap restores the prior current junction (real Windows runner only)" { + _windows_runner || skip "requires a real Windows runner to exercise the Windows reparse-point traversal" + local root old new + root=$(acq_provider_input_root sbx winswap) + mkdir -p "$root/generations/generation.old/models/providers" "$root/generations/generation.new/models/providers" + old="$root/generations/generation.old"; new="$root/generations/generation.new" + printf old > "$old/models/providers/marker" + printf new > "$new/models/providers/marker" + + run _acq_provider_input_publish "$root" "$old" + assert_success + # Fail the swap; `current` must stay the old junction (restored), not be left + # absent or redirected to the new generation. + _acq_provider_input_atomic_replace() { return 1; } + run _acq_provider_input_publish "$root" "$new" + assert_failure + [ "$(cat "$root/current/models/providers/marker" 2>/dev/null)" = "old" ] + [ -d "$old" ] + [ -d "$new" ] +} + + +# --------------------------------------------------------------------------- +# MSYS argv conversion must not resolve links before the walk sees the path +# --------------------------------------------------------------------------- +# MSYS2 rewrites POSIX-looking argv for native programs and, in doing so, +# resolves an intermediate junction/symlink. The helpers therefore disable that +# conversion and translate to the native form lexically. These tests simulate the +# conversion on Linux with a python3 shim that resolves symlinks in its argv +# exactly when conversion has not been disabled. Whether real MSYS honors +# MSYS2_ARG_CONV_EXCL / MSYS_NO_PATHCONV for this call, and the cygpath mount +# mapping, are verifiable only on a Windows host (the windows: tests above). + +_install_resolving_python_shim() { + mkdir -p "$STUBDIR/shim" + cat > "$STUBDIR/shim/python3" <<'SHIM' +#!/bin/bash +convert=1 +[ -n "${MSYS2_ARG_CONV_EXCL:-}" ] && convert=0 +[ -n "${MSYS_NO_PATHCONV:-}" ] && convert=0 +args=() +for a in "$@"; do + if [ "$convert" = 1 ] && [[ "$a" == /* ]]; then a=$(realpath -m "$a"); fi + args+=("$a") +done +exec /usr/bin/python3 "${args[@]}" +SHIM + chmod +x "$STUBDIR/shim/python3" + PATH="$STUBDIR/shim:$PATH" +} + +@test "conversion shim control: it resolves a symlinked ancestor when conversion is not disabled" { + _install_resolving_python_shim + local target="$STUBDIR/shim_target" link="$STUBDIR/shim_link" + mkdir -p "$target"; ln -s "$target" "$link" + run python3 -c 'import sys; print(sys.argv[1])' "$link/child" + assert_output "$target/child" + MSYS_NO_PATHCONV=1 run python3 -c 'import sys; print(sys.argv[1])' "$link/child" + assert_output "$link/child" +} + +@test "native_path is the identity on a non-MSYS host" { + run _acq_provider_input_native_path "/tmp/a/link/child" + assert_output "/tmp/a/link/child" +} + +@test "native_path maps only the mount component and keeps the rest lexical on MSYS" { + local log="$STUBDIR/cygpath.log"; : > "$log" + _acq_provider_input_is_msys() { return 0; } + cygpath() { printf '%s\n' "$*" >> "$log"; case "$2" in /c) printf 'C:/' ;; *) printf 'D:/mnt%s' "$2" ;; esac; } + run _acq_provider_input_native_path "/c/Users/x/link/new_child" + assert_output "C:/Users/x/link/new_child" + run _acq_provider_input_native_path "/tmp/a/link/new_child" + assert_output "D:/mnt/tmp/a/link/new_child" + # cygpath only ever saw a single mount component, never the user-controlled tail. + run cat "$log" + refute_output --partial "Users" + refute_output --partial "link" + run _acq_provider_input_native_path 'C:\Users\x\link' + assert_output "C:/Users/x/link" + run _acq_provider_input_native_path "relative/path" + assert_output "relative/path" + run _acq_provider_input_native_path "/../x" + assert_failure +} + +@test "portable walk: a path-conversion shim cannot bypass the ancestor check in safe_mkdir" { + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + _install_resolving_python_shim + local target="$STUBDIR/cv_target" base="$STUBDIR/cv_base" + mkdir -p "$base" "$target"; ln -s "$target" "$base/link" + run _acq_provider_input_safe_mkdir "$base/link/new_child" + assert_failure + [ ! -e "$target/new_child" ] + run _acq_provider_input_safe_mkdir "$base/link/deep/er/new_child" + assert_failure + [ -z "$(ls -A "$target")" ] +} + +@test "portable walk: a path-conversion shim cannot bypass the ancestor check in safe_dir" { + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + _install_resolving_python_shim + local target="$STUBDIR/cvd_target" base="$STUBDIR/cvd_base" + mkdir -p "$base" "$target/sub"; ln -s "$target" "$base/link" + run _acq_provider_input_safe_dir "$base/link/sub" + assert_failure +} + +# The snapshot half below only proves the wrapper still produces a working +# snapshot under the shim; snapshot does not walk its target ancestors, so the +# ancestor-link rejection itself is asserted on the mkdir walk. +@test "portable walk: the MSYS-form path walk rejects a shim-resolvable ancestor and snapshot still works" { + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + _install_resolving_python_shim + _acq_provider_input_is_msys() { return 0; } + cygpath() { printf '%s' "$2"; } + local target="$STUBDIR/cvm_target" base="$STUBDIR/cvm_base" + mkdir -p "$base" "$target"; ln -s "$target" "$base/link" + run _acq_provider_input_safe_mkdir "$base/link/new_child" + assert_failure + [ ! -e "$target/new_child" ] + + local release="$STUBDIR/release" kit + _provider_release "$release"; _provider_kit "$release" alpha + kit="$release/integrations/isolation/acq-kits/alpha" + run _acq_provider_input_snapshot "$kit" "$STUBDIR/cvm_ok_candidate" + assert_success + [ -f "$STUBDIR/cvm_ok_candidate/provider-input.json" ] +} + @test "canonical_path aliases /var /tmp /etc on Darwin via ACQ_TEST_HOST_OS" { # Exercises the production _acq_provider_input_canonical_path helper directly # with ACQ_TEST_HOST_OS=darwin so the Darwin branch runs on Linux CI. @@ -1430,3 +1669,89 @@ EOF grep -Fqx "fetch -q --depth 1 origin $PATTERNS_KIT_REF" "$log" grep -Fqx "checkout -q --detach FETCH_HEAD" "$log" } + +@test "candidate snapshot validates artifact paths before creating any directory" { + local release="$STUBDIR/release" kit candidate="$STUBDIR/candidate-order" bad + _provider_release "$release" + for bad in "../escape-rel/facts.json" "$STUBDIR/escape-abs/facts.json"; do + _provider_kit "$release" alpha + kit="$release/integrations/isolation/acq-kits/alpha" + printf '{"providerId":"alpha","factsPath":"%s","normalizerPath":"scripts/normalize","snapshotPath":"files/snapshot.json"}\n' "$bad" > "$kit/provider-input.json" + rm -rf "$candidate" + run _acq_provider_input_snapshot "$kit" "$candidate" + assert_failure + [ ! -e "$STUBDIR/escape-rel" ] + [ ! -e "$STUBDIR/escape-abs" ] + [ ! -e "$candidate/../escape-rel" ] + done +} + +@test "candidate snapshot rejects drive-qualified and backslash artifact paths on the portable walk" { + # The artifacts really exist (those names are legal on POSIX), so only the + # path-form check, not a missing file, can make the snapshot fail. + local release="$STUBDIR/release" kit candidate="$STUBDIR/candidate-win" bad + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + _provider_release "$release" + for bad in 'C:foo/facts.json' 'provider-input\facts.json'; do + _provider_kit "$release" alpha + kit="$release/integrations/isolation/acq-kits/alpha" + mkdir -p "$kit/$(dirname "$bad")" + printf '{"providerId":"alpha"}\n' > "$kit/$bad" + python3 -c 'import json,sys; print(json.dumps({"providerId":"alpha","factsPath":sys.argv[1],"normalizerPath":"scripts/normalize","snapshotPath":"files/snapshot.json"}))' "$bad" > "$kit/provider-input.json" + rm -rf "$candidate" + run _acq_provider_input_snapshot "$kit" "$candidate" + assert_failure + done + # Control: the same layout with a plain relative path is accepted. + _provider_kit "$release" alpha + run _acq_provider_input_snapshot "$release/integrations/isolation/acq-kits/alpha" "$candidate-ok" + assert_success +} + +# The portable (Windows) lstat walk is selected on POSIX by the offline-only +# ACQ_TEST_FORCE_PORTABLE_WALK seam so its symlink-ancestor rejection runs in CI. +# Real junction/reparse behavior is covered only by the Windows-runner tests above. + +@test "portable walk: snapshot copies a real kit and rejects a symlinked artifact ancestor" { + local release="$STUBDIR/release" kit outside="$STUBDIR/outside-kit" candidate="$STUBDIR/candidate-pw" + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + _provider_release "$release"; _provider_kit "$release" alpha + kit="$release/integrations/isolation/acq-kits/alpha" + run _acq_provider_input_snapshot "$kit" "$candidate" + assert_success + [ -f "$candidate/provider-input/facts.json" ] + + mkdir -p "$outside"; printf '{"providerId":"alpha"}\n' > "$outside/facts.json" + rm -rf "$kit/provider-input"; ln -s "$outside" "$kit/provider-input" + rm -rf "$candidate" + run _acq_provider_input_snapshot "$kit" "$candidate" + assert_failure +} + +@test "portable walk: safe_mkdir and safe_dir accept real paths and reject symlinked ancestors" { + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + local real="$STUBDIR/pw_real" target="$STUBDIR/pw_target" link="$STUBDIR/pw_link" + run _acq_provider_input_safe_mkdir "$real/a/b" + assert_success + [ -d "$real/a/b" ] + run _acq_provider_input_safe_dir "$real/a/b" + assert_success + + mkdir -p "$target"; ln -s "$target" "$link" + run _acq_provider_input_safe_mkdir "$link/child" + assert_failure + [ ! -e "$target/child" ] + run _acq_provider_input_safe_dir "$link" + assert_failure + run _acq_provider_input_safe_dir "$link/nope" + assert_failure +} + +@test "portable walk: safe_mkdir does not follow a symlink planted mid-path" { + export ACQ_TEST_FORCE_PORTABLE_WALK=1 + local target="$STUBDIR/pw_mid_target" base="$STUBDIR/pw_mid" + mkdir -p "$base" "$target"; ln -s "$target" "$base/inner" + run _acq_provider_input_safe_mkdir "$base/inner/deep/er" + assert_failure + [ -z "$(ls -A "$target")" ] +} diff --git a/test/bats/151-heredoc-in-substitution.bats b/test/bats/151-heredoc-in-substitution.bats new file mode 100644 index 00000000..b4fe3bed --- /dev/null +++ b/test/bats/151-heredoc-in-substitution.bats @@ -0,0 +1,35 @@ +#!/usr/bin/env bats +# bash 3.2 does not understand a here-document inside a command or process +# substitution: it scans the heredoc body for quote and parenthesis balance and +# fails with "bad substitution" at parse time. Newer bash accepts the same text, +# so only the bash 3.2 job would notice. These tests catch the hazard under any +# bash. + +load 'helper' + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +@test "no heredoc inside a command or process substitution has an unsafe body" { + run python3 "$REPO_ROOT/scripts/scan-heredoc-subst.py" "$REPO_ROOT/acq" "$REPO_ROOT"/acq.backends/*.sh "$REPO_ROOT"/scripts/* + assert_success +} + +@test "the scan flags an unsafe heredoc in a process substitution and passes a safe one" { + local unsafe="$STUBDIR/unsafe.sh" safe="$STUBDIR/safe.sh" + printf '%s\n' 'while read -r v; do :; done < <(python3 - <<'"'"'PY'"'"'' "# it's unsafe" 'PY' ')' > "$unsafe" + printf '%s\n' 'while read -r v; do :; done < <(python3 - <<'"'"'PY'"'"'' 'print(1)' 'PY' ')' > "$safe" + run python3 "$REPO_ROOT/scripts/scan-heredoc-subst.py" "$unsafe" + assert_failure + assert_output --partial 'odd single quotes' + run python3 "$REPO_ROOT/scripts/scan-heredoc-subst.py" "$safe" + assert_success +} + +@test "facts reader output carries no carriage return" { + local record="$STUBDIR/facts.json" out + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"https://alpha.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' > "$record" + out=$(acq_provider_facts_json_fields "$record"; echo END) + [ "${out#*$'\r'}" = "$out" ] + case "$out" in alpha*END) ;; *) false ;; esac +}