Skip to content
Merged
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
68 changes: 68 additions & 0 deletions crates/nono-cli/tests/symlink_hop_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Kernel-level coverage: a multi-hop symlink grant must resolve under
//! the real Seatbelt sandbox.

#![cfg(target_os = "macos")]

use nono_test_support::{Argv, nono_test};
use std::fs;

/// `.gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig`, with
/// `hosts/current -> hosts/mymac` a symlinked directory component.
fn write_fixture(workspace: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let hosts = workspace.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("create hosts/mymac");
fs::write(mymac.join("gitconfig"), "trusted\n").expect("write real gitconfig");
fs::write(mymac.join("secret.txt"), "topsecret\n").expect("write sibling secret");

let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, &current).expect("symlink hosts/current -> hosts/mymac");

let gitconfig_link = workspace.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink .gitconfig -> hosts/current/gitconfig");

(gitconfig_link, mymac.join("secret.txt"))
}

#[test]
fn multi_hop_symlinked_leaf_resolves_through_symlinked_directory() {
let t = nono_test!("symlink-hop-positive");
let workspace = t.workspace().to_path_buf();
let (gitconfig_link, _secret) = write_fixture(&workspace);

let profile = t.write_profile(
"symlink-hop-positive",
&format!(
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
gitconfig_link.display()
),
);

t.run()
.profile(&profile)
.exec(Argv::new("/bin/cat").arg(&gitconfig_link))
.assert_stdout_contains("trusted");
}

#[test]
fn multi_hop_symlinked_leaf_does_not_widen_access_to_sibling_in_traversed_directory() {
let t = nono_test!("symlink-hop-negative");
let workspace = t.workspace().to_path_buf();
let (gitconfig_link, secret) = write_fixture(&workspace);

let profile = t.write_profile(
"symlink-hop-negative",
&format!(
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
gitconfig_link.display()
),
);

t.run()
.profile(&profile)
.exec(Argv::new("/bin/cat").arg(&secret))
.assert_failure(
"granting a multi-hop symlink must not expose sibling files under the traversed directory",
);
}
68 changes: 68 additions & 0 deletions crates/nono-cli/tests/symlink_hop_run_linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Linux Landlock parity, proving empirically that `open_path_rule`'s
//! `O_PATH` resolution needs no code change here.

#![cfg(target_os = "linux")]

use nono_test_support::{Argv, nono_test};
use std::fs;

/// `.gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig`, with
/// `hosts/current -> hosts/mymac` a symlinked directory component.
fn write_fixture(workspace: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let hosts = workspace.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("create hosts/mymac");
fs::write(mymac.join("gitconfig"), "trusted\n").expect("write real gitconfig");
fs::write(mymac.join("secret.txt"), "topsecret\n").expect("write sibling secret");

let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, &current).expect("symlink hosts/current -> hosts/mymac");

let gitconfig_link = workspace.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink .gitconfig -> hosts/current/gitconfig");

(gitconfig_link, mymac.join("secret.txt"))
}

#[test]
fn multi_hop_symlinked_leaf_resolves_through_symlinked_directory() {
let t = nono_test!("symlink-hop-positive-linux");
let workspace = t.workspace().to_path_buf();
let (gitconfig_link, _secret) = write_fixture(&workspace);

let profile = t.write_profile(
"symlink-hop-positive-linux",
&format!(
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
gitconfig_link.display()
),
);

t.run()
.profile(&profile)
.exec(Argv::new("/bin/cat").arg(&gitconfig_link))
.assert_stdout_contains("trusted");
}

#[test]
fn multi_hop_symlinked_leaf_does_not_widen_access_to_sibling_in_traversed_directory() {
let t = nono_test!("symlink-hop-negative-linux");
let workspace = t.workspace().to_path_buf();
let (gitconfig_link, secret) = write_fixture(&workspace);

let profile = t.write_profile(
"symlink-hop-negative-linux",
&format!(
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
gitconfig_link.display()
),
);

t.run()
.profile(&profile)
.exec(Argv::new("/bin/cat").arg(&secret))
.assert_failure(
"granting a multi-hop symlink must not expose sibling files under the traversed directory",
);
}
173 changes: 172 additions & 1 deletion crates/nono/src/path.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Path utilities shared across nono library and CLI.

use std::path::{Path, PathBuf};
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};

/// Canonicalize a path using an ancestor-walk fallback.
///
Expand Down Expand Up @@ -49,6 +50,79 @@ pub(crate) fn try_canonicalize_ancestor_walk(path: &Path) -> PathBuf {
path.to_path_buf()
}

/// Matches typical OS `ELOOP`/`MAXSYMLINKS` limits, so a cycle truncates
/// instead of looping forever.
const MAX_SYMLINK_HOPS: usize = 40;

/// Intermediate hops need their own sandbox grant, since
/// [`try_canonicalize`] only ever sees the original and resolved endpoints.
pub fn collect_symlink_hops(path: &Path) -> Vec<PathBuf> {
let mut hops = Vec::new();
let mut seen = HashSet::new();
let mut depth = 0usize;
resolve_hops(path, &mut hops, &mut seen, &mut depth);
hops
}

/// `path` need not be absolute on entry, since a symlink target can be
/// relative to its own parent directory.
fn resolve_hops(
path: &Path,
hops: &mut Vec<PathBuf>,
seen: &mut HashSet<PathBuf>,
depth: &mut usize,
) -> PathBuf {
let mut result = PathBuf::new();

for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
result.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
Component::Normal(segment) => {
result.push(segment);

if *depth >= MAX_SYMLINK_HOPS {
continue;
}

let Ok(metadata) = std::fs::symlink_metadata(&result) else {
continue;
};
if !metadata.file_type().is_symlink() {
continue;
}

*depth += 1;
if !seen.insert(result.clone()) {
continue;
}
hops.push(result.clone());

let Ok(target) = std::fs::read_link(&result) else {
continue;
};
let target = if target.is_absolute() {
target
} else {
result
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target)
};

result = resolve_hops(&target, hops, seen, depth);
}
}
}

result
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -93,4 +167,101 @@ mod tests {
assert_eq!(result, real_file.canonicalize().expect("canonicalize"));
}
}

#[cfg(unix)]
#[test]
fn collect_symlink_hops_empty_for_plain_path() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let real_file = canonical_dir.join("real.txt");
fs::write(&real_file, "hello").expect("write file");
assert!(collect_symlink_hops(&real_file).is_empty());
}

#[cfg(unix)]
#[test]
fn collect_symlink_hops_single_hop_leaf_symlink() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let real_file = canonical_dir.join("real.txt");
fs::write(&real_file, "hello").expect("write file");
let link = canonical_dir.join("link.txt");
std::os::unix::fs::symlink(&real_file, &link).expect("symlink");

let hops = collect_symlink_hops(&link);
assert_eq!(hops, vec![link]);
}

/// A symlinked leaf through a symlinked directory component:
/// `.gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig`.
#[cfg(unix)]
#[test]
fn collect_symlink_hops_multi_hop_through_symlinked_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");

let hosts = canonical_dir.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("mkdir mymac");
let real_gitconfig = mymac.join("gitconfig");
fs::write(&real_gitconfig, "[user]\n").expect("write gitconfig");

let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, &current).expect("symlink dir");

let gitconfig_link = canonical_dir.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink leaf");

let hops = collect_symlink_hops(&gitconfig_link);
assert_eq!(hops, vec![gitconfig_link.clone(), current]);

// The fully resolved target is unaffected -- std canonicalization
// still collapses the whole chain in one shot.
assert_eq!(
try_canonicalize(&gitconfig_link),
real_gitconfig.canonicalize().expect("canonicalize")
);
}

#[cfg(unix)]
#[test]
fn collect_symlink_hops_cycle_truncates_instead_of_looping() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
let a = canonical_dir.join("a");
let b = canonical_dir.join("b");
std::os::unix::fs::symlink(&b, &a).expect("symlink a->b");
std::os::unix::fs::symlink(&a, &b).expect("symlink b->a");

let hops = collect_symlink_hops(&a);
// Must terminate (this test itself is the proof); depth-bounded, so
// only ever sees the two distinct literal hop paths.
assert!(hops.len() <= MAX_SYMLINK_HOPS);
assert!(hops.contains(&a));
assert!(hops.contains(&b));
}

#[cfg(unix)]
#[test]
fn collect_symlink_hops_nonexistent_final_target_keeps_existing_hops() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");

let hosts = canonical_dir.join("hosts");
let mymac = hosts.join("mymac");
fs::create_dir_all(&mymac).expect("mkdir mymac");
// Note: no `gitconfig` file written under mymac -- final target
// does not exist, but the directory symlink hop is still real.

let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, &current).expect("symlink dir");

let gitconfig_link = canonical_dir.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink leaf");

let hops = collect_symlink_hops(&gitconfig_link);
assert_eq!(hops, vec![gitconfig_link, current]);
}
}
27 changes: 27 additions & 0 deletions crates/nono/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3843,6 +3843,33 @@ mod tests {
}
}

/// `open_path_rule` opens `cap.resolved` with `O_PATH`, so the kernel
/// resolves the whole chain before Landlock sees a literal path.
#[test]
fn test_open_path_rule_resolves_multi_hop_symlink_through_symlinked_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize");

let hosts = canonical_dir.join("hosts");
let mymac = hosts.join("mymac");
std::fs::create_dir_all(&mymac).expect("mkdir mymac");
let real_gitconfig = mymac.join("gitconfig");
std::fs::write(&real_gitconfig, "[user]\n").expect("write gitconfig");

let current = hosts.join("current");
std::os::unix::fs::symlink(&mymac, &current).expect("symlink dir");

let gitconfig_link = canonical_dir.join(".gitconfig");
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
.expect("symlink leaf");

let cap = crate::capability::FsCapability::new_file(&gitconfig_link, AccessMode::Read)
.expect("gitconfig capability");

let rule = open_path_rule(&cap, ABI::V5).expect("open multi-hop symlink rule");
assert!(rule.access.effective.contains(AccessFs::ReadFile));
}

#[test]
fn test_open_path_rule_rejects_removed_path() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
Loading