Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/tui/src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ pub(crate) fn set_test_artifact_sessions_root(root: Option<PathBuf>) -> Option<P
std::mem::replace(&mut *guard, root)
}

/// The recorded spelling of a session artifact path: for receipts, metadata
/// and display, not for opening. It is built from the state root as spelled
/// (a user may have relocated that root behind a link), so an opener must go
/// through `open_session_relative` instead of this value; readers that do
/// consume it compare canonicalized forms.
#[must_use]
pub fn session_artifact_absolute_path(session_id: &str, relative_path: &Path) -> Option<PathBuf> {
if !is_valid_session_id(session_id) {
Expand Down Expand Up @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions crates/tui/src/fleet/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Self::open(workspace, relative, create)
}

fn open_confined(
workspace: &Path,
relative: &Path,
Expand Down Expand Up @@ -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<Self> {
let resolved = workspace.canonicalize()?;
Self::open(&resolved, relative, create)
}

pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result<Self> {
Self::open(workspace, relative, false)
}
Expand Down Expand Up @@ -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;
Expand All @@ -1081,6 +1164,13 @@ impl WorkspaceFile {
pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
Self::open(workspace, relative, create)
}
pub(crate) fn open_resolved_root(
workspace: &Path,
relative: &Path,
create: bool,
) -> io::Result<Self> {
Self::open(workspace, relative, create)
}
pub(crate) fn sibling(&self, _: &str) -> io::Result<Self> {
unreachable!()
}
Expand Down
4 changes: 2 additions & 2 deletions crates/tui/src/runtime_api/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
14 changes: 14 additions & 0 deletions crates/tui/src/runtime_api/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ApiError> {
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(
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-blocking-calls-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
Loading