@@ -370,6 +370,139 @@ jobs:
370370 || (echo "Expected path to not exist on dash prefix miss" && exit 1)
371371 echo "Dash prefix safety: OK"
372372
373+ # Restore-target safety guard. cache-restore.sh does an
374+ # unconditional `rm -rf "$path_to_cache"` on a stale-marker
375+ # re-sync, so a misconfigured workflow passing path: /, path: $HOME,
376+ # or a relative path could wipe the runner. The guard rejects
377+ # those targets up-front with exit 2 before any filesystem side
378+ # effect. One test per rejection class plus a positive control
379+ # confirming a safe sibling path is still accepted.
380+ - name : Restore-target guard rejects root
381+ run : |
382+ set +e
383+ out=$(sh lib/cache-restore.sh / any-key /tmp/local-cache "" 2>&1)
384+ rc=$?
385+ set -e
386+ [ "$rc" = "2" ] \
387+ || (echo "Expected exit 2 for root path, got $rc. Output: $out" && exit 1)
388+ echo "$out" | grep -q 'system root' \
389+ || (echo "Expected 'system root' in error, got: $out" && exit 1)
390+ echo "Reject /: OK"
391+
392+ - name : Restore-target guard rejects /.
393+ run : |
394+ set +e
395+ out=$(sh lib/cache-restore.sh /. any-key /tmp/local-cache "" 2>&1)
396+ rc=$?
397+ set -e
398+ [ "$rc" = "2" ] \
399+ || (echo "Expected exit 2 for /., got $rc. Output: $out" && exit 1)
400+ echo "Reject /.: OK"
401+
402+ - name : Restore-target guard rejects /..
403+ run : |
404+ set +e
405+ out=$(sh lib/cache-restore.sh /.. any-key /tmp/local-cache "" 2>&1)
406+ rc=$?
407+ set -e
408+ [ "$rc" = "2" ] \
409+ || (echo "Expected exit 2 for /.., got $rc. Output: $out" && exit 1)
410+ echo "Reject /..: OK"
411+
412+ - name : Restore-target guard rejects relative paths
413+ run : |
414+ set +e
415+ out=$(sh lib/cache-restore.sh some/relative any-key /tmp/local-cache "" 2>&1)
416+ rc=$?
417+ set -e
418+ [ "$rc" = "2" ] \
419+ || (echo "Expected exit 2 for relative path, got $rc. Output: $out" && exit 1)
420+ echo "$out" | grep -q 'must be absolute' \
421+ || (echo "Expected 'must be absolute' in error, got: $out" && exit 1)
422+ echo "Reject relative: OK"
423+
424+ - name : Restore-target guard rejects whitespace-only paths
425+ run : |
426+ set +e
427+ out=$(sh lib/cache-restore.sh " " any-key /tmp/local-cache "" 2>&1)
428+ rc=$?
429+ set -e
430+ # cache-restore.sh's empty check runs first for unambiguously
431+ # empty strings; tabs/spaces get past it and hit the new
432+ # whitespace-only guard. Both produce exit 2, which is the
433+ # invariant we're asserting.
434+ [ "$rc" = "2" ] \
435+ || (echo "Expected exit 2 for whitespace-only path, got $rc. Output: $out" && exit 1)
436+ echo "Reject whitespace-only: OK"
437+
438+ - name : Restore-target guard rejects $HOME
439+ run : |
440+ set +e
441+ out=$(HOME=/tmp/guard-home sh lib/cache-restore.sh /tmp/guard-home any-key /tmp/local-cache "" 2>&1)
442+ rc=$?
443+ set -e
444+ [ "$rc" = "2" ] \
445+ || (echo "Expected exit 2 for HOME path, got $rc. Output: $out" && exit 1)
446+ echo "$out" | grep -q 'HOME' \
447+ || (echo "Expected HOME in error, got: $out" && exit 1)
448+ echo "Reject \$HOME: OK"
449+
450+ - name : Restore-target guard rejects parents of $HOME
451+ run : |
452+ set +e
453+ out=$(HOME=/tmp/guard-parent/home sh lib/cache-restore.sh /tmp/guard-parent any-key /tmp/local-cache "" 2>&1)
454+ rc=$?
455+ set -e
456+ [ "$rc" = "2" ] \
457+ || (echo "Expected exit 2 for parent of HOME, got $rc. Output: $out" && exit 1)
458+ echo "$out" | grep -q 'HOME' \
459+ || (echo "Expected HOME in error, got: $out" && exit 1)
460+ echo "Reject parent of \$HOME: OK"
461+
462+ - name : Restore-target guard rejects $GITHUB_WORKSPACE
463+ run : |
464+ set +e
465+ # Unset HOME so the HOME check doesn't fire first and mask the
466+ # GITHUB_WORKSPACE-specific diagnostic we're asserting on. Also
467+ # strip the runner's own GITHUB_WORKSPACE and RUNNER_WORKSPACE
468+ # from the outer env so we exercise exactly the value we pass.
469+ out=$(env -u HOME -u RUNNER_WORKSPACE GITHUB_WORKSPACE=/tmp/guard-ws sh lib/cache-restore.sh /tmp/guard-ws any-key /tmp/local-cache "" 2>&1)
470+ rc=$?
471+ set -e
472+ [ "$rc" = "2" ] \
473+ || (echo "Expected exit 2 for GITHUB_WORKSPACE path, got $rc. Output: $out" && exit 1)
474+ echo "$out" | grep -q 'GITHUB_WORKSPACE' \
475+ || (echo "Expected GITHUB_WORKSPACE in error, got: $out" && exit 1)
476+ echo "Reject \$GITHUB_WORKSPACE: OK"
477+
478+ - name : Restore-target guard rejects parents of $RUNNER_WORKSPACE
479+ run : |
480+ set +e
481+ out=$(env -u HOME -u GITHUB_WORKSPACE RUNNER_WORKSPACE=/tmp/guard-rw/inner sh lib/cache-restore.sh /tmp/guard-rw any-key /tmp/local-cache "" 2>&1)
482+ rc=$?
483+ set -e
484+ [ "$rc" = "2" ] \
485+ || (echo "Expected exit 2 for parent of RUNNER_WORKSPACE, got $rc. Output: $out" && exit 1)
486+ echo "$out" | grep -q 'RUNNER_WORKSPACE' \
487+ || (echo "Expected RUNNER_WORKSPACE in error, got: $out" && exit 1)
488+ echo "Reject parent of \$RUNNER_WORKSPACE: OK"
489+
490+ - name : Restore-target guard accepts a safe sibling path
491+ run : |
492+ # Positive control: a path under /tmp that is not the root, not
493+ # an ancestor of any guarded variable, and has no cache entry
494+ # should produce a clean cache miss (exit 0) rather than a
495+ # rejection. This confirms the guard is not over-rejecting.
496+ set +e
497+ out=$(env -u HOME -u GITHUB_WORKSPACE -u RUNNER_WORKSPACE sh lib/cache-restore.sh /tmp/guard-safe-sibling no-such-key /tmp/local-cache "" 2>&1)
498+ rc=$?
499+ set -e
500+ [ "$rc" = "0" ] \
501+ || (echo "Expected exit 0 for safe sibling path, got $rc. Output: $out" && exit 1)
502+ echo "$out" | grep -q 'Cache miss' \
503+ || (echo "Expected 'Cache miss' in output, got: $out" && exit 1)
504+ echo "Accept safe sibling: OK"
505+
373506 # Sequential idempotent save: verify that a save against an already-
374507 # populated entry exits cleanly without touching the existing content.
375508 # This does NOT exercise parallel mutex contention — real contention is
0 commit comments