Skip to content

feat(minimald)!: rebase sftp paths at / to support accessing /home - #1164

Merged
twitchyliquid64 merged 2 commits into
mainfrom
tom/sftp
Aug 3, 2026
Merged

feat(minimald)!: rebase sftp paths at / to support accessing /home#1164
twitchyliquid64 merged 2 commits into
mainfrom
tom/sftp

Conversation

@twitchyliquid64

@twitchyliquid64 twitchyliquid64 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Rebases paths over sftp as rooted at the same tree as in the corresponding session, so that /home and /workbench are both accessible. Previously, / mapped to /workbench which was limiting.

Summary by CodeRabbit

  • New Features
    • SFTP sessions now provide separate client-visible roots for workspace and home directories.
    • Added consistent path normalization and synthetic root directory listings.
  • Bug Fixes
    • Improved protection against directory traversal, symlink escapes, unsafe root operations, and insufficient permissions.
    • Git status checks now verify the resolved work tree, including nested repositories and symlinks.
  • Improvements
    • Enhanced metadata, file access, directory operations, and real paths across exported locations.
    • Updated session documentation and manifest examples to use the workspace path.

Note

Rebase SFTP paths at / to expose both /workbench and /home exports

  • Introduces a synthetic SFTP root (/) containing two exports: /workbench (session working tree) and /home (user home directory), replacing the previous single-root layout.
  • Rewrites path resolution in sftp.rs to normalize client paths textually, map them to the correct export, and enforce containment via a symlink-safe contained walk.
  • Adds resolve_below_export to reject destructive operations (remove, rename, rmdir) targeting the export roots themselves.
  • Updates the integration test upload path from /minimal.toml to /workbench/minimal.toml to match the new layout.
  • Risk: Breaking change — SFTP clients that referenced paths directly under / (e.g. /foo) must now use /workbench/foo; symlinked ancestors or dangling symlink leaves inside exports are refused with PermissionDenied.

Changes since #1164 opened

  • Modified russh_sftp::server::Handler::setstat implementation for SftpSession to enforce minimum owner permissions by performing fstat on the file handle to determine if target is a directory, then calling a new helper function to apply a permission floor of owner read for files or owner read-execute for directories before applying permissions to the filesystem [da91cab]
  • Added keep_owner_access function that accepts std::fs::Permissions and a directory boolean flag, returning modified permissions with an enforced minimum of owner read-execute for directories or owner read for files while preserving all other permission bits [da91cab]
  • Added sftp_setstat_leaves_the_owner_able_to_reach_what_it_chmodded integration test that creates a session, writes test files and directories, exercises setstat with various permission modes including 0o000, 0o644, 0o751, and 0o444, and validates that resulting permissions are floored to 0o400 for files and 0o500 for directories when restrictive modes are requested while preserving requested group and other bits [da91cab]

Macroscope summarized 52b6979.

@twitchyliquid64
twitchyliquid64 requested a review from a team as a code owner August 3, 2026 19:42
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Minimald now exposes separate workspace and home roots through SFTP. It validates canonical containment, protects synthetic and export roots, and uses typed paths. VCS detection verifies the resolved Git work-tree. Absolute paths support component-aware prefix checks.

Changes

Dual-root SFTP namespace

Layer / File(s) Summary
Path and root contracts
crates/minimald/src/env.rs, crates/paths/src/lib.rs, crates/minimald/src/sftp.rs, crates/minimald/Cargo.toml
Root helpers are crate-visible. AbsPath::starts_with checks component-aligned prefixes. SFTP uses typed paths and two client-visible exports.
Session roots and target resolution
crates/minimald/src/sftp.rs
Session initialization canonicalizes workspace and home directories. Client paths map to contained daemon paths. Traversal and symlink escapes are rejected.
SFTP operation enforcement
crates/minimald/src/sftp.rs
Metadata, file, directory, rename, removal, and permission operations handle the synthetic root and export roots.
Namespace and escape validation
crates/minimald/src/sftp.rs, crates/minvmd/tests/minimald_session_integration.rs
Tests cover dual exports, root behavior, traversal, root protection, escaping symlinks, and the /workbench upload path.

VCS root validation

Layer / File(s) Summary
VCS workspace validation
crates/minimald/src/session_delta.rs
VCS assessment compares Git’s resolved work-tree with the requested workspace. Tests cover fake markers and nested directories.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SFTPClient
  participant SFTPSession
  participant Filesystem
  SFTPClient->>SFTPSession: Send client-visible path
  SFTPSession->>Filesystem: Resolve and canonicalize target
  Filesystem-->>SFTPSession: Return contained daemon path
  SFTPSession->>Filesystem: Execute SFTP operation
  Filesystem-->>SFTPSession: Return metadata or operation result
  SFTPSession-->>SFTPClient: Return client-visible response
Loading

Possibly related PRs

Poem

A rabbit maps two roots with care,
/workbench and /home appear.
Symlinks stop at guarded ground,
Git checks the work-tree bound.
Typed paths keep each burrow sound.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the SFTP change, but it omits the required Testing and Checklist sections, including test evidence and the BREAKING CHANGE footer. Add the required Testing and Checklist sections, include test commands and results, and add a BREAKING CHANGE footer for the incompatible path behavior.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the breaking SFTP path change and follows the required Conventional Commit format.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tom/sftp

Comment @coderabbitai help to get the list of available commands.

@bryan-minimal

Copy link
Copy Markdown
Member

Reviewed with the escape from the #1162 campaign in mind (the resolve() NotFound branch that fell back to a merely lexical check, letting a planted workspace/link -> / be written through). This closes it, and closes it the right way rather than by adding another check:

  • Roots canonicalized once at construction, with the /var -> /private/var reason stated — so both sides of every comparison are symlink-free.
  • The deepest-existing-ancestor walk, canonicalizing that and re-appending the missing tail, is the thing the old code got wrong: it refuses a symlinked parent before anything is created through it, which canonicalizing only existing paths cannot do. Same shape as env::create_artifact, which is the one implementation in the tree that already had this right.
  • Termination is argued explicitly rather than assumed.

Two things, one worth acting on:

1. No O_NOFOLLOW on the final open (opts.open(host.as_str())).

resolve() proves the path is contained, then the open re-resolves it by name. Between those two steps the leaf can be swapped for a symlink, and the open follows it. That is a narrow race, but the racer here is not remote — sandbox2 bind-mounts workspace and home read-write into the sandbox (crates/sandbox2/src/lib.rs:713-740), so an in-session shell can spin on it locally against a daemon-uid write.

.custom_flags(libc::O_NOFOLLOW) on the OpenOptions closes the leaf case for the cost of one flag. It does not close a mid-path swap, but the ancestor walk already covers the interesting part of that, and the leaf is the one an attacker can actually aim at.

Same consideration for the mkdir / rename / remove / setstat handlers if they re-resolve by name after the check.

2. Observation, not an objection — the export surface roughly doubles.

/ previously mapped to /workbench alone; it now exposes home as well. Both are RW bind-mounted into the sandbox, so the plant-a-symlink primitive now applies to both roots. The containment logic looks symmetric across the two (both canonicalized, both routed through the same resolve_below_export), so I think this is fine — flagging it so the symmetry is a deliberate invariant rather than an accident, since a future third export root would need the same treatment.


Context that may be useful: this bug class has now recurred six times in the tree as "one containment rule, two implementations, only one hardened" — most recently in #1162, where switching tar extraction to a per-entry loop closed the symlink hole and opened a hardlink one (Entry::unpack passes target_base: None, so the kernel resolves hardlink targets against the process CWD; Archive::unpack routed through unpack_in and did not). Tracked in #1163, including a proposed clippy.toml disallowed-methods entry that would have caught it at compile time.

Two related items from the same audit are still open and independent of this PR: op::materialize::extract_raw_file (crates/op/src/materialize.rs:256) has no containment check at all — [outputs.x] type="raw-file" path="../../../etc/shadow" streams daemon-side bytes to the client — and lcache::LocalDir's write paths are lexical-only. Happy to take those.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/minimald/src/sftp.rs (1)

404-416: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Close the symlink race on open with O_NOFOLLOW.

contained proves containment at check time. For a leaf that does not exist yet, the proof covers only the parent chain. A concurrent writer in the same session — the sandbox process shares these directories — can create a symlink at that leaf after the check and before this open. opts.open then follows it and writes outside the export.

crates/minimald/src/env.rs create_artifact (line 1294) already sets custom_flags(libc::O_NOFOLLOW) for this exact reason. Apply the same flag here. Note that O_NOFOLLOW makes open fail with ELOOP on a symlinked final component, so a client that legitimately opens a symlink inside an export loses that ability; decide whether SFTP needs to support it.

🔒 Proposed change
         let host = self.resolve(&filename).await?.real()?;
+        use std::os::unix::fs::OpenOptionsExt;
         let mut opts = OpenOptions::new();
         opts.read(pflags.contains(OpenFlags::READ))
             .write(pflags.contains(OpenFlags::WRITE))
             .append(pflags.contains(OpenFlags::APPEND))
-            .truncate(pflags.contains(OpenFlags::TRUNCATE));
+            .truncate(pflags.contains(OpenFlags::TRUNCATE))
+            // The containment proof is about the path at check time; refuse
+            // to follow a link planted at the leaf since then.
+            .custom_flags(libc::O_NOFOLLOW);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/sftp.rs` around lines 404 - 416, Update the OpenOptions
construction in the SFTP open flow before opts.open to set the platform-specific
custom O_NOFOLLOW flag, matching env.rs create_artifact. Preserve the existing
read/write/create flag handling so final-component symlinks fail with the
underlying ELOOP error rather than being followed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/session_delta.rs`:
- Around line 172-179: Update vcs_at_risk and its filesystem checks to avoid
synchronous Path::exists() and Path::canonicalize() on Tokio workers. Execute
both probes through spawn_blocking or Tokio filesystem APIs, and wrap each
corresponding await with WALK_TIMEOUT while preserving the existing
repository-path comparison behavior.

In `@crates/minimald/src/sftp.rs`:
- Around line 554-556: Update the setstat permission-update path around resolve
and perm_from_mode to use resolve_below_export before calling real(), preventing
clients from changing permissions on export roots while preserving permission
updates for paths below them.
- Around line 168-175: Update SftpSession::new to canonicalize the
tree/workspace and home roots after directory creation, then reject them when
either canonical path equals or contains the other. Return an error before
constructing the session or exposing exports, while preserving normal
initialization for disjoint roots; use the existing SftpSession fields and
exports method for locating the relevant paths.

---

Outside diff comments:
In `@crates/minimald/src/sftp.rs`:
- Around line 404-416: Update the OpenOptions construction in the SFTP open flow
before opts.open to set the platform-specific custom O_NOFOLLOW flag, matching
env.rs create_artifact. Preserve the existing read/write/create flag handling so
final-component symlinks fail with the underlying ELOOP error rather than being
followed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2cf0e423-6bc0-43b0-ac76-622ec6db22e0

📥 Commits

Reviewing files that changed from the base of the PR and between 3e1fcbd and 0e924a1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • crates/minimald/Cargo.toml
  • crates/minimald/src/env.rs
  • crates/minimald/src/session_delta.rs
  • crates/minimald/src/sftp.rs
  • crates/paths/src/lib.rs
💤 Files with no reviewable changes (1)
  • crates/minimald/Cargo.toml

Comment on lines +172 to +179
// A `.git` entry is only a marker. When it is not a real repository, git
// does not fail: it keeps walking up and answers for the nearest enclosing
// repo, which would report an unrelated tree's uncommitted files as this
// session's work at risk. So the work tree git found has to be this one.
let top = run_git(root, &["rev-parse", "--show-toplevel"]).await?;
if !is_same_dir(Path::new(top.trim()), root) {
return None;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- session_delta.rs outline ---'
ast-grep outline crates/minimald/src/session_delta.rs --view expanded
printf '%s\n' '--- target implementation and tests ---'
sed -n '1,240p' crates/minimald/src/session_delta.rs
printf '%s\n' '--- is_same_dir usages ---'
rg -n -C 3 'is_same_dir|vcs_at_risk|WALK_TIMEOUT|canonicalize' crates/minimald/src crates/minimald/tests 2>/dev/null || true
printf '%s\n' '--- tokio dependency/features ---'
rg -n -C 3 'tokio' Cargo.toml crates/*/Cargo.toml

Repository: gominimal/minimal

Length of output: 45700


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- assess callers and runtime context ---'
rg -n -C 5 'assess\(|session_delta::|DeltaSource::arm|changed_files\(' crates/minimald/src crates/minimald/tests 2>/dev/null || true
printf '%s\n' '--- synchronous filesystem calls in async session_delta functions ---'
python3 - <<'PY'
from pathlib import Path
p = Path("crates/minimald/src/session_delta.rs")
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 140 <= i <= 225:
        print(f"{i:4}: {line}")
PY
printf '%s\n' '--- async filesystem handling precedent ---'
sed -n '320,345p' crates/minimald/src/sftp.rs
sed -n '1275,1310p' crates/minimald/src/env.rs
printf '%s\n' '--- just recipes relevant to minimald ---'
rg -n -C 2 'test-cross|test.*minimald|minimald' Justfile justfile* 2>/dev/null || true

Repository: gominimal/minimal

Length of output: 25030


🌐 Web query:

Tokio official documentation spawn_blocking cancellation timeout blocking operations

💡 Result:

In Tokio, tasks spawned using spawn_blocking cannot be cancelled or aborted once they have started running because they are synchronous and do not cooperate with the asynchronous task system [1][2][3]. Key points regarding cancellation and timeouts with spawn_blocking: 1. Cancellation: Calling abort on a JoinHandle for a spawn_blocking task has no effect if the task is already running [1][4]. The abort method only works if the task has not yet started and is still waiting in the thread pool queue [1][3]. 2. Timeouts: Wrapping a spawn_blocking call in a tokio::time::timeout future will cause the timeout to fire if the task takes too long, but this only stops the async task from waiting for the result [5]. The blocking operation itself will continue to run to completion in its background thread, regardless of the timeout [1][2]. 3. Recommended Pattern for Cancellation: Since Tokio cannot force a blocking task to stop, you must implement cooperative cancellation yourself [6]. This is typically done by passing a cancellation signal into the blocking closure, such as an AtomicBool or a oneshot::channel [6][7]. The blocking code should periodically check this signal and return early if it is set [8][7]. If your application requires prompt cancellation or strict timing, consider whether the task can be refactored into truly asynchronous code, or use a dedicated thread pool (like rayon for CPU-bound tasks) where you can manage the lifecycle of the threads more directly [4][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

src = Path("crates/minimald/src/session_delta.rs").read_text()
m = re.search(r'async fn vcs_at_risk\(.*?(?=\n///|\nfn |\nasync fn )', src, re.S)
assert m, "vcs_at_risk not found"
body = m.group(0)
checks = {
    "vcs_at_risk is async": body.startswith("async fn vcs_at_risk"),
    "root marker uses synchronous exists": ".exists()" in body,
    "canonicalization occurs in async call path": "is_same_dir(Path::new(top.trim()), root)" in body,
}
for name, ok in checks.items():
    print(f"{name}: {'yes' if ok else 'no'}")
assert all(checks.values())

helper = re.search(r'fn is_same_dir\(.*?(?=\n///|\nfn |\nasync fn )', src, re.S)
assert helper, "is_same_dir not found"
helper_body = helper.group(0)
print("is_same_dir uses std canonicalize:", "canonicalize()" in helper_body)
print("is_same_dir uses spawn_blocking:", "spawn_blocking" in helper_body)
print("is_same_dir uses timeout:", "timeout(" in helper_body)
assert "canonicalize()" in helper_body
assert "spawn_blocking" not in helper_body
assert "timeout(" not in helper_body
PY
printf '%s\n' '--- Tokio runtime declarations ---'
rg -n -C 3 '#\[tokio::main|Builder::new_|new_current_thread|new_multi_thread|flavor\s*=' crates/minimald/src crates/minimald/tests 2>/dev/null || true

Repository: gominimal/minimal

Length of output: 44876


Move filesystem probes off the Tokio runtime.

vcs_at_risk calls synchronous Path::exists() and Path::canonicalize(). A slow filesystem can block a Tokio worker. Run both probes with spawn_blocking or Tokio filesystem APIs, and bound their awaits with WALK_TIMEOUT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session_delta.rs` around lines 172 - 179, Update
vcs_at_risk and its filesystem checks to avoid synchronous Path::exists() and
Path::canonicalize() on Tokio workers. Execute both probes through
spawn_blocking or Tokio filesystem APIs, and wrap each corresponding await with
WALK_TIMEOUT while preserving the existing repository-path comparison behavior.

Source: Learnings

Comment thread crates/minimald/src/sftp.rs
Comment thread crates/minimald/src/sftp.rs Outdated
Comment on lines +554 to +556
let host = self.resolve(&path).await?.real()?;
if let Some(perms) = perm_from_mode(attrs.permissions) {
fs::set_permissions(host, perms).await?;
fs::set_permissions(host.as_str(), perms).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

setstat still lets a client chmod an export root.

rmdir, remove, and rename use resolve_below_export, so the export roots are protected. setstat uses resolve(...).real(), so chmod 000 /workbench succeeds and strips access to the session's working tree for the sandbox process. The directory survives, but the session's layout becomes unusable.

If the export roots are part of the session layout the client must not change, use resolve_below_export here too.

🛡️ Proposed change
-        let host = self.resolve(&path).await?.real()?;
+        // The export roots are the session's layout: permissions on them are
+        // not the client's to change, for the same reason `rmdir` refuses.
+        let host = self.resolve_below_export(&path).await?;
         if let Some(perms) = perm_from_mode(attrs.permissions) {
             fs::set_permissions(host.as_str(), perms).await?;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let host = self.resolve(&path).await?.real()?;
if let Some(perms) = perm_from_mode(attrs.permissions) {
fs::set_permissions(host, perms).await?;
fs::set_permissions(host.as_str(), perms).await?;
// The export roots are the session's layout: permissions on them are
// not the client's to change, for the same reason `rmdir` refuses.
let host = self.resolve_below_export(&path).await?;
if let Some(perms) = perm_from_mode(attrs.permissions) {
fs::set_permissions(host.as_str(), perms).await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/sftp.rs` around lines 554 - 556, Update the setstat
permission-update path around resolve and perm_from_mode to use
resolve_below_export before calling real(), preventing clients from changing
permissions on export roots while preserving permission updates for paths below
them.

@bryan-minimal

Copy link
Copy Markdown
Member

Re-reviewed after the update. The O_NOFOLLOW work looks right, and the reasoning is the part I'd have gotten wrong:

resolve hands back a canonical path, so a real symlink is already resolved to its target by the time we get here and there is no link left to refuse.

That's the argument that makes O_NOFOLLOW safe here rather than a behaviour regression. Adding it to a handler that resolves by name without canonicalizing would break legitimate symlink access; because resolve canonicalizes, the flag can only ever reject a leaf that appeared after the check — which is exactly the race and nothing else. Good, and the comment says so plainly, including that a mid-path swap is the ancestor walk's job rather than this flag's.

The O_NONBLOCK catch is better than what I raised. A FIFO planted in the workspace makes a read-only open block until a writer appears, wedging the dispatch loop — a denial of service that needs no traversal at all, just a mkfifo from the in-session shell. I flagged the symlink race and missed that one entirely.

The remaining handlers check out for the reasons that make them uninteresting rather than because of new code, which is worth stating so it's a deliberate conclusion:

  • unlink / rename operate on the link itself, not its target, so a swapped-in leaf gets removed/renamed rather than followed.
  • mkdir onto an existing symlink is EEXIST; a symlinked parent is caught by the ancestor walk.
  • stat uses symlink_metadata on an already-canonical path, so it is equivalent to stat without the following.

One residual, lower severity than the open, and possibly a deliberate accept: fs::read_dir(host.as_str()) still opens by path and follows the leaf. Same window as the open had — swap the resolved directory for a symlink between resolve and read_dir and the listing comes from outside the export root. It is an information disclosure rather than a write, and std has no O_NOFOLLOW equivalent for read_dir (it needs O_DIRECTORY|O_NOFOLLOW + fdopendir, i.e. nix), so "not worth the machinery for a listing" is a reasonable call — I'd just want it to be a stated one rather than an oversight, since the next reader will notice the asymmetry with open.

Nothing blocking from me. LGTM.

@twitchyliquid64
twitchyliquid64 enabled auto-merge (rebase) August 3, 2026 20:23
@twitchyliquid64
twitchyliquid64 merged commit 09271c5 into main Aug 3, 2026
30 checks passed
@twitchyliquid64
twitchyliquid64 deleted the tom/sftp branch August 3, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants