Skip to content

cloud credential transfer tests fail on macOS: secure_directory O_NOFOLLOW ancestor walk rejects symlinked TMPDIR (/var -> /private/var) #1947

Open

Description

@alecuba16

Summary

The new cloud credential transfer validation (secure_directory in crates/jcode-base/src/auth/transfer.rs) walks every HOME/TMPDIR ancestor from / with O_NOFOLLOW. On macOS the default TMPDIR is /var/folders/... and /var is a symlink to /private/var, so the ancestor walk fails with UnsafePath and 17 of the 22 auth::transfer tests fail locally on any standard macOS machine. CI macos passes only because its HOME/TMPDIR are real dirs.

Steps to reproduce

  1. On macOS (default TMPDIR=/var/folders/...), checkout master at 7d342c1.
  2. cargo test -p jcode-base auth::transfer
  3. 17/22 tests fail with Err(UnsafePath) at the import_at/export_at ancestor validation.
  4. Same command with TMPDIR set to a symlink-free real dir (e.g. under /Users/alejandro.blanco): all 22 pass.

Also reproduced with TMPDIR=/tmp (symlink to /private/tmp): same 17 failures.

The related cloud acceptance path prints: cloud protocol probe failed: The credential store path is not a safe regular file or private directory. and the cli::cloud_move::latency_tests fail the same way in a scrubbed env.

Expected

Tests and the transfer feature work with the OS-default temporary directory on macOS, or the validation canonicalizes the path before the ancestor walk.

Actual

secure_directory rejects any path whose ancestors include a symlink (standard macOS layout), returning UnsafePath.

Proposed fix

Resolve symlinks once before the descriptor walk (e.g. std::fs::canonicalize on the base path, then walk the resolved path with the existing O_NOFOLLOW per-component safety), keeping the per-component checks for concurrent-rename safety. Tests should then pass with the macOS default TMPDIR.

Happy to open a PR if you want.

Activity

  1. added
    bugSomething isn't working
    securitySecurity hardening or security-sensitive changes.
    platform: macosmacOS-specific behavior or compatibility.
    on Oct 24, 2026
  2. 1jehuang commented on Oct 24, 2026

    @1jehuang
    Owner

    Thanks for the clear macOS reproduction. This is security-sensitive credential-path validation, so I will not replace the no-follow walk with canonicalization without reviewing the trust boundary and symlink/rename race cases. Recommended next step: define which OS-managed ancestor aliases are permitted, retain descriptor-relative validation of the private store, and verify both default macOS TMPDIR and hostile-symlink cases. Leaving this open for that review.


    Jcode agent (automated triage), on behalf of @1jehuang

  3. ianalitis commented on Oct 25, 2026

    @ianalitis
    Contributor

    Reproduced on current master (4bb16b96c), macOS 26 arm64 (Darwin 25.6.0).

    Same counts as reported, from the same binary, changing only TMPDIR:

    $ TMPDIR=/Users/ianalitis/.jcode/scratch ./jcode_base auth::transfer
    test result: ok. 22 passed; 0 failed; ...
    
    $ TMPDIR=/var/folders/_4/.../T/ ./jcode_base auth::transfer
    test result: FAILED. 5 passed; 17 failed; 0 ignored

    Failure shape is uniform:

    thread 'auth::transfer::tests::cloud_handoff_replaces_stale_rotated_copy_but_not_other_identity' panicked at
    crates/jcode-base/src/auth/transfer.rs:1277:73:
    called `Result::unwrap()` on an `Err` value: UnsafePath
    

    Cause is where the report says it is: secure_directory (crates/jcode-base/src/auth/transfer.rs:479) walks from / to the target with O_NOFOLLOW | O_DIRECTORY, and rejects the first ancestor that is not a real directory. On macOS /var is a symlink to /private/var, so every descendant of the default TMPDIR is rejected before the target is ever opened. With TMPDIR=/tmp the same thing happens (/tmp -> /private/tmp), which confirms it is the ancestor prefix, not the leaf.

    So this is not a flaky test: it is the security check rejecting a path shape that every standard macOS user has. CI macOS stays green only because the runner's HOME/TMPDIR have no symlinked ancestors, which means the suite is effectively not exercising the check on the platform users run.

    Left triage: needs-decision because fixing it is a policy choice about what the walk should permit, not a mechanical patch. Three options, roughly:

    1. Canonicalize each ancestor before comparing, and keep O_NOFOLLOW on the final open only. Preserves the "do not follow a symlink into the target" property while accepting vendor path shapes.
    2. Walk only real directories under HOME and require HOME itself to be canonical and owned by the current user, treating the TMPDIR prefix as trusted above that point.
    3. Keep the strict walk and change the tests to build their own symlink-free scratch root. Cheapest, but leaves jcode refusing a standard macOS TMPDIR at runtime, so it hides the user-visible behavior rather than fixing it.

    Option 1 is the one that matches the intent documented in the code; option 3 would make the tests pass while shipping the same runtime rejection. Happy to implement whichever is chosen.


    — Jcode agent (automated triage), on behalf of @1jehuang

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingplatform: macosmacOS-specific behavior or compatibility.securitySecurity hardening or security-sensitive changes.triage: needs-decisionNeeds maintainer decision/design thought

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions