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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ clean_env = true
env = { CC = "gcc", LANG = "C.UTF-8" }

[filesystem]
read = ["/usr", "/lib", "/lib64", "/bin", "/etc"]
read = ["/usr", "/lib", "/lib64", "/bin", "/etc", "${HOME}/.cargo"]
write = ["/tmp/work"]

[limits]
Expand All @@ -483,6 +483,11 @@ processes = 50
extra_deny = []
```

Path fields expand `${HOME}`, so the same profile works on machines with
different usernames. `${HOME}` is the only variable; anything else, including
a leading `~`, is a load error. See
[docs/sandbox-reference.md](docs/sandbox-reference.md) for the full grammar.

```bash
sandlock profile list
sandlock profile show build
Expand Down
49 changes: 36 additions & 13 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! Runs a workload under observation and emits a sandlock profile TOML
//! usable by `sandlock run -p`.

use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

Expand Down Expand Up @@ -702,7 +702,7 @@ pub async fn run(args: LearnArgs) -> Result<()> {
// TODO: learn limits.open_files via supervisor once open_files enforcement is implemented.

// --merge: union observed profile into an existing one.
// Start from the existing profile so all fields we don't observe
// Start from the existing profile so all fields we don't observe
// are preserved. Then union in the observed fields.
if let Some(ref merge_path) = args.merge {
let existing_toml = std::fs::read_to_string(merge_path)
Expand All @@ -714,19 +714,42 @@ pub async fn run(args: LearnArgs) -> Result<()> {
let observed = profile_out;
profile_out = existing.clone();

// Union filesystem paths, re-run dedup after merging.
let merged_reads: Vec<PathBuf> = {
let mut set: BTreeSet<PathBuf> = observed.filesystem.read.iter().cloned().collect();
set.extend(existing.filesystem.read.iter().cloned());
dedup_subsumed(set.into_iter().collect())
// Union filesystem paths, re-run dedup after merging. Dedup keys on
// the expanded path but writes back the raw entry, so a profile's
// ${HOME} survives instead of being replaced by this machine's
// absolute path.
let mut home: Option<String> = None;
let mut expand_key = |p: &PathBuf| -> Result<PathBuf> {
let s = p.to_string_lossy();
if !s.contains('$') {
return Ok(p.clone());
}
if home.is_none() {
home = Some(sandlock_core::expand::resolve_home()?);
}
// Observed kernel paths can contain a literal $, which the grammar
// rejects; key them raw so an odd filename cannot abort the merge.
match sandlock_core::expand::expand(&s, home.as_deref().unwrap()) {
Ok(e) => Ok(PathBuf::from(e)),
Err(_) => Ok(p.clone()),
}
};
let merged_writes: Vec<PathBuf> = {
let mut set: BTreeSet<PathBuf> = observed.filesystem.write.iter().cloned().collect();
set.extend(existing.filesystem.write.iter().cloned());
dedup_subsumed(set.into_iter().collect())
let mut merge_paths = |observed: &[PathBuf], existing: &[PathBuf]| -> Result<Vec<PathBuf>> {
let mut by_expanded: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
for p in observed {
by_expanded.insert(expand_key(p)?, p.clone());
}
// Existing entries land second so their raw spelling wins.
for p in existing {
by_expanded.insert(expand_key(p)?, p.clone());
}
let kept = dedup_subsumed(by_expanded.keys().cloned().collect());
Ok(kept.into_iter().map(|k| by_expanded[&k].clone()).collect())
};
profile_out.filesystem.read = merged_reads;
profile_out.filesystem.write = merged_writes;
profile_out.filesystem.read =
merge_paths(&observed.filesystem.read, &existing.filesystem.read)?;
profile_out.filesystem.write =
merge_paths(&observed.filesystem.write, &existing.filesystem.write)?;

// Union network.allow.
let mut allow_set: std::collections::BTreeSet<String> =
Expand Down
32 changes: 32 additions & 0 deletions crates/sandlock-cli/tests/learn_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,4 +956,36 @@ fn test_learn_captures_https_request() {
assert!(profile.contains(ca_bundle), "expected ca bundle path in [config]: {profile}");
}

/// --merge keeps a hand-written ${HOME} and does not append its expanded twin.
#[test]
fn test_learn_merge_preserves_home_variable() {
let home = std::env::var("HOME").expect("HOME set");
let probe_dir = std::path::PathBuf::from(&home).join(".config");
std::fs::create_dir_all(&probe_dir).expect("create probe dir");
let probe = probe_dir.join("sandlock-merge-probe");
std::fs::write(&probe, b"ok").expect("write probe");

let profile = tempfile::NamedTempFile::new().expect("tempfile");
let profile_path = profile.path().to_str().unwrap().to_owned();
std::fs::write(&profile_path, "[filesystem]\nread = [\"${HOME}/.config\", \"/usr\"]\n")
.expect("seed profile");

let learn = sandlock_bin()
.args(["learn", "--merge", &profile_path, "--", "cat", probe.to_str().unwrap()])
.output()
.expect("failed to run sandlock learn --merge");
let _ = std::fs::remove_file(&probe);
assert!(learn.status.success(),
"merge learn failed: {}", String::from_utf8_lossy(&learn.stderr));

let merged = std::fs::read_to_string(&profile_path).expect("read merged profile");
let parsed: sandlock_core::ProfileInput = toml::from_str(&merged).expect("parse merged");
let reads: Vec<String> = parsed.filesystem.read.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();

assert!(reads.iter().any(|r| r == "${HOME}/.config"),
"merge dropped the ${{HOME}} spelling: {reads:?}");
assert!(!reads.iter().any(|r| r.starts_with(&format!("{home}/.config"))),
"merge added a machine-specific duplicate of ${{HOME}}/.config: {reads:?}");
}
39 changes: 39 additions & 0 deletions crates/sandlock-cli/tests/profile_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,42 @@ fn no_supervisor_rejects_supervisor_only_profile_fields() {
stderr,
);
}

#[test]
fn profile_home_variable_grants_real_access() {
let home = std::env::var("HOME").expect("HOME set");
let probe = std::path::PathBuf::from(&home).join(".sandlock-expand-probe");
std::fs::write(&probe, b"ok").expect("write probe");

let tmp = tempfile::tempdir().unwrap();
let profile_path = tmp.path().join("expand.toml");
// The write grant is what proves expansion happened: it is mandatory, so
// a literal "${HOME}" would abort the run on a path that does not exist.
std::fs::write(&profile_path, format!(r#"
[filesystem]
{read}
write = ["${{HOME}}/.sandlock-expand-probe"]
"#, read = read_list())).unwrap();

let out = sandlock_bin()
.args([
"run",
"--profile-file",
profile_path.to_str().unwrap(),
"--fs-read",
probe.to_str().unwrap(),
"--",
"/bin/cat",
probe.to_str().unwrap(),
])
.output()
.expect("spawn sandlock");

std::fs::remove_file(&probe).ok();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
}
2 changes: 1 addition & 1 deletion crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ description = "Lightweight process sandbox using Landlock, seccomp-bpf, and secc
[dependencies]
libc = "0.2"
syscalls = { version = "0.8", default-features = false }
nix = { version = "0.29", features = ["process", "signal", "fs", "ioctl", "poll"] }
nix = { version = "0.29", features = ["process", "signal", "fs", "ioctl", "poll", "user"] }
tokio = { version = "1", features = ["rt", "net", "time", "sync", "macros", "io-util", "fs"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
Expand Down
Loading
Loading