diff --git a/crates/tui/src/artifacts.rs b/crates/tui/src/artifacts.rs index bbbb328afe..c4c19adabf 100644 --- a/crates/tui/src/artifacts.rs +++ b/crates/tui/src/artifacts.rs @@ -122,6 +122,11 @@ pub(crate) fn set_test_artifact_sessions_root(root: Option) -> Option

Option { if !is_valid_session_id(session_id) { @@ -207,7 +212,10 @@ pub(crate) fn open_session_relative( if create { std::fs::create_dir_all(&root)?; } - crate::fleet::files::WorkspaceFile::open( + // The sessions root is the application's own state, which a user may have + // relocated behind a link (on Windows, a junction to another volume). + // Resolve the root once; everything below it stays link-refusing. + crate::fleet::files::WorkspaceFile::open_resolved_root( &root, &PathBuf::from(session_id).join(relative_path), create, diff --git a/crates/tui/src/fleet/files.rs b/crates/tui/src/fleet/files.rs index 13198ddfd1..77c49988ce 100644 --- a/crates/tui/src/fleet/files.rs +++ b/crates/tui/src/fleet/files.rs @@ -81,6 +81,19 @@ impl WorkspaceFile { Self::open_confined(workspace, relative, false, false, false) } + /// Open under a root that may itself be reached through user-owned links: + /// the application's own state directory, which a user may have relocated + /// behind a symlink. The Unix opener already resolves the root and still + /// refuses every link below it, so this is the same call under a name that + /// says why a caller wants it. Not for a caller-supplied workspace root. + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + Self::open(workspace, relative, create) + } + fn open_confined( workspace: &Path, relative: &Path, @@ -616,6 +629,21 @@ impl WorkspaceFile { }) } + /// Open under a root that may itself be reached through user-owned links: + /// the application's own state directory, which a user may have relocated + /// behind a junction. The root is resolved once to its real directory and + /// the same link-refusing walk then runs, so links below the root are still + /// refused. The caller must have created the root already. Not for a + /// caller-supplied workspace root. + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + let resolved = workspace.canonicalize()?; + Self::open(&resolved, relative, create) + } + pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result { Self::open(workspace, relative, false) } @@ -1067,6 +1095,61 @@ mod windows_publication_tests { } } +#[cfg(all(test, windows))] +mod windows_root_link_tests { + use super::*; + + fn make_junction(link: &Path, target: &Path) { + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .expect("invoke Windows junction creation"); + assert!( + output.status.success(), + "failed to create junction: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// A state root relocated behind a junction: the strict opener refuses the + /// root link, `open_resolved_root` resolves it once and writes through, + /// and a link below the root stays refused either way. + #[test] + fn linked_state_root_resolves_once_and_keeps_the_inner_rule() { + let temp = tempfile::tempdir().unwrap(); + let real = temp.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = temp.path().join("link"); + make_junction(&link, &real); + + let relative = Path::new("state/entry.txt"); + assert!( + WorkspaceFile::open(&link, relative, true).is_err(), + "the strict opener must refuse a linked root" + ); + + WorkspaceFile::open_resolved_root(&link, relative, true) + .unwrap() + .publish(b"payload") + .unwrap(); + assert_eq!( + std::fs::read(real.join("state").join("entry.txt")).unwrap(), + b"payload" + ); + + let elsewhere = temp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + make_junction(&real.join("linked-child"), &elsewhere); + assert!( + WorkspaceFile::open_resolved_root(&link, Path::new("linked-child/x.txt"), true) + .is_err(), + "a link below the resolved root must stay refused" + ); + } +} + #[cfg(all(not(unix), not(windows)))] #[derive(Debug)] pub(crate) struct WorkspaceFile; @@ -1081,6 +1164,13 @@ impl WorkspaceFile { pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result { Self::open(workspace, relative, create) } + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + Self::open(workspace, relative, create) + } pub(crate) fn sibling(&self, _: &str) -> io::Result { unreachable!() } diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index db489907b3..3acc49399b 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -1395,7 +1395,7 @@ pub(super) fn resolve_session_artifact( } }; let relative = PathBuf::from(id).join(&relative_path); - let opened = super::workspace::open_confined_file(sessions_dir, &relative, false) + let opened = super::workspace::open_confined_file_resolved(sessions_dir, &relative, false) .and_then(|file| super::workspace::read_confined_bytes(&file)); let read = match (opened, &authority) { (Ok(read), _) => read, @@ -1481,7 +1481,7 @@ fn image_evidence_summary( > { let relative = PathBuf::from(id) .join(crate::tools::large_output_router::evidence_metadata_relative_path(artifact_id)); - let file = super::workspace::open_confined_file(sessions_dir, &relative, false)?; + let file = super::workspace::open_confined_file_resolved(sessions_dir, &relative, false)?; let evidence = crate::tools::large_output_router::read_evidence_metadata_file(&file) .map_err(|error| super::workspace::map_fs_error(error, "evidence metadata"))?; let expected_path = diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index 19f4a21bc9..ddb4a40870 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -566,6 +566,20 @@ pub(super) fn open_confined_file( .map_err(|error| map_fs_error(error, "file")) } +/// [`open_confined_file`] for the application's own state root, which a user +/// may have relocated behind a link (on Windows, a junction to another +/// volume). The root is resolved once and every component below it is still +/// refused when linked, so a reader follows its writer and a caller-supplied +/// workspace root keeps the stricter [`open_confined_file`]. +pub(super) fn open_confined_file_resolved( + root: &FsPath, + relative: &FsPath, + create: bool, +) -> Result { + crate::fleet::files::WorkspaceFile::open_resolved_root(root, relative, create) + .map_err(|error| map_fs_error(error, "file")) +} + /// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so /// the revision always describes the whole file. pub(super) fn read_confined_bytes( diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 7ff3947535..04da909ec4 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -178,7 +178,7 @@ "std_fs": 1 }, "crates/tui/src/fleet/files.rs": { - "path_canonicalize": 1, + "path_canonicalize": 2, "std_fs": 6, "thread_sleep": 1 },