Restore persistent system path composition

This commit is contained in:
ms2k 2026-07-22 03:07:42 +09:00
commit 7aff8e542e
4 changed files with 338 additions and 30 deletions

View file

@ -113,6 +113,7 @@ impl Ipv4Prefix {
pub struct SandboxLayoutV1 {
protected_destinations: BTreeSet<String>,
nested_transient_overrides: BTreeSet<String>,
}
impl fmt::Debug for SandboxLayoutV1 {
@ -120,6 +121,10 @@ impl fmt::Debug for SandboxLayoutV1 {
formatter
.debug_struct("SandboxLayoutV1")
.field("protected_destinations", &self.protected_destinations)
.field(
"nested_transient_overrides",
&self.nested_transient_overrides,
)
.finish()
}
}
@ -132,9 +137,21 @@ impl SandboxLayoutV1 {
/// include `/workspace`.
pub fn from_protected_destinations(
destinations: impl IntoIterator<Item = String>,
) -> Result<Self, ConfigurationError> {
Self::from_destination_policy(destinations, [])
}
/// Builds protected destination classes supplied by sandbox-layout construction.
///
/// # Errors
/// Destinations must be normalized, non-root, unique across classes, and include
/// `/workspace`; nested overrides must be exact product-owned transient mounts.
pub fn from_destination_policy(
exclusive: impl IntoIterator<Item = String>,
nested_transient_overrides: impl IntoIterator<Item = String>,
) -> Result<Self, ConfigurationError> {
let mut protected_destinations = BTreeSet::new();
for destination in destinations {
for destination in exclusive {
validate_sandbox_path(&destination)?;
if !protected_destinations.insert(destination) {
return Err(ConfigurationError::new(
@ -143,6 +160,16 @@ impl SandboxLayoutV1 {
));
}
}
let mut nested = BTreeSet::new();
for destination in nested_transient_overrides {
validate_sandbox_path(&destination)?;
if !nested.insert(destination.clone()) || !protected_destinations.insert(destination) {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"duplicate protected sandbox destination",
));
}
}
if !protected_destinations.contains("/workspace") {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
@ -151,6 +178,7 @@ impl SandboxLayoutV1 {
}
Ok(Self {
protected_destinations,
nested_transient_overrides: nested,
})
}
@ -158,6 +186,11 @@ impl SandboxLayoutV1 {
pub fn protected_destinations(&self) -> &BTreeSet<String> {
&self.protected_destinations
}
#[must_use]
pub fn nested_transient_overrides(&self) -> &BTreeSet<String> {
&self.nested_transient_overrides
}
}
pub struct ConfinedPathPlan {

View file

@ -265,6 +265,7 @@ fn validate_owned_directory(descriptor: &OwnedFd, uid: u32) -> Result<(), Config
pub(super) fn validate_persistent_destinations(
destinations: &[String],
protected: &BTreeSet<String>,
nested_transient_overrides: &BTreeSet<String>,
) -> Result<Vec<String>, ConfigurationError> {
require_pairwise_non_ancestral(
destinations.iter().map(String::as_str),
@ -272,11 +273,32 @@ pub(super) fn validate_persistent_destinations(
)?;
for destination in destinations {
validate_sandbox_path(destination)?;
require_clear_of_protected(destination, protected)?;
require_persistent_clear_of_protected(destination, protected, nested_transient_overrides)?;
}
Ok(destinations.to_vec())
}
fn require_persistent_clear_of_protected(
destination: &str,
protected: &BTreeSet<String>,
nested_transient_overrides: &BTreeSet<String>,
) -> Result<(), ConfigurationError> {
let conflict = protected.iter().any(|protected| {
destination == protected
|| sandbox_is_strict_descendant(destination, protected)
|| (sandbox_is_strict_descendant(protected, destination)
&& !nested_transient_overrides.contains(protected))
});
if conflict {
Err(ConfigurationError::new(
ConfigurationErrorKind::PathConflict,
"configured sandbox destination intersects a protected destination",
))
} else {
Ok(())
}
}
pub(super) fn require_clear_of_protected(
destination: &str,
protected: &BTreeSet<String>,

View file

@ -150,6 +150,7 @@ pub fn load_trusted_configuration(
let persistent_dirs = validate_persistent_destinations(
&selected.persistent_dirs,
request.sandbox_layout.protected_destinations(),
request.sandbox_layout.nested_transient_overrides(),
)?;
let (effective_allow_prefixes, effective_deny_prefixes) =
resolve_network(&configuration, selected)?;

View file

@ -75,6 +75,15 @@ fn request(
tree: &TestTree,
config: PathBuf,
environment: BTreeMap<String, std::ffi::OsString>,
) -> ConfigurationLoadRequest {
request_with_layout(tree, config, environment, layout())
}
fn request_with_layout(
tree: &TestTree,
config: PathBuf,
environment: BTreeMap<String, std::ffi::OsString>,
sandbox_layout: SandboxLayoutV1,
) -> ConfigurationLoadRequest {
let workspace = tree.path().join("workspace");
let runtime = tree.path().join("runtime");
@ -89,7 +98,7 @@ fn request(
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment,
sandbox_layout: layout(),
sandbox_layout,
}
}
@ -196,6 +205,64 @@ fn sandbox_layout_is_closed_normalized_and_requires_workspace() {
.is_err()
);
}
assert!(
SandboxLayoutV1::from_destination_policy(["/workspace".into()], ["/workspace".into()])
.is_err()
);
}
#[test]
fn persistent_parents_allow_only_classified_transient_children() {
let layout = SandboxLayoutV1::from_destination_policy(
[
"/workspace".into(),
"/run/lazaret".into(),
"/proc".into(),
"/sys".into(),
"/dev".into(),
],
[
"/etc/resolv.conf".into(),
"/etc/hosts".into(),
"/etc/hostname".into(),
],
)
.unwrap();
let tree = TestTree::new();
let body = valid_configuration(&tree).replace(
"persistent_dirs = [\"/root\"]",
"persistent_dirs = [\"/root\", \"/usr\", \"/var\", \"/etc\", \"/opt\"]",
);
let config = write_configuration(&tree, &body);
let environment = BTreeMap::from([("HOST_TOKEN".into(), "value".into())]);
let resolved =
load_trusted_configuration(request_with_layout(&tree, config, environment, layout))
.unwrap();
assert_eq!(
resolved.fingerprint_record().persistent_dirs,
["/root", "/usr", "/var", "/etc", "/opt"]
);
for persistent in ["/run", "/run/lazaret/state", "/etc/hosts"] {
let case = TestTree::new();
let body = valid_configuration(&case).replace(
"persistent_dirs = [\"/root\"]",
&format!("persistent_dirs = [\"{persistent}\"]"),
);
let config = write_configuration(&case, &body);
let environment = BTreeMap::from([("HOST_TOKEN".into(), "value".into())]);
let layout = SandboxLayoutV1::from_destination_policy(
["/workspace".into(), "/run/lazaret".into()],
["/etc/hosts".into()],
)
.unwrap();
assert_eq!(
load_trusted_configuration(request_with_layout(&case, config, environment, layout,))
.unwrap_err()
.kind(),
ConfigurationErrorKind::PathConflict
);
}
}
#[test]