Restore persistent system path composition

This commit is contained in:
ms2k 2026-07-22 03:07:42 +09:00
commit 7aff8e542e
11 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]

View file

@ -525,22 +525,46 @@ fn destination_layout_is_materialized_and_canonical_in_artifact_identity() {
first.id_map = id_map_with_container_root();
first.destinations = vec![
PreparedDestination {
path: "/workspace".into(),
path: "/root".into(),
kind: PreparedDestinationKind::Directory,
},
PreparedDestination {
path: "/run/lazaret/bin/entry".into(),
path: "/usr".into(),
kind: PreparedDestinationKind::Directory,
},
PreparedDestination {
path: "/var".into(),
kind: PreparedDestinationKind::Directory,
},
PreparedDestination {
path: "/etc".into(),
kind: PreparedDestinationKind::Directory,
},
PreparedDestination {
path: "/opt".into(),
kind: PreparedDestinationKind::Directory,
},
PreparedDestination {
path: "/etc/resolv.conf".into(),
kind: PreparedDestinationKind::RegularFile,
},
PreparedDestination {
path: "/workspace".into(),
kind: PreparedDestinationKind::Directory,
path: "/etc/hosts".into(),
kind: PreparedDestinationKind::RegularFile,
},
PreparedDestination {
path: "/etc/hostname".into(),
kind: PreparedDestinationKind::RegularFile,
},
];
let artifact = fixture.store.prepare(&first).unwrap();
let root = fixture.artifact_root(&artifact);
assert!(root.join("workspace").is_dir());
assert!(root.join("run/lazaret/bin/entry").is_file());
for destination in ["root", "usr", "var", "etc", "opt"] {
assert!(root.join(destination).is_dir());
}
for destination in ["etc/resolv.conf", "etc/hosts", "etc/hostname"] {
assert!(root.join(destination).is_file());
}
let mut reordered = fixture.request(73, &[(bytes.clone(), OCI_TAR)]);
reordered.id_map = id_map_with_container_root();

View file

@ -25,6 +25,7 @@ use crate::network::{
NetworkAttestationContext, PreparedNetwork, enter_generation_filesystem_namespaces,
enter_generation_network_namespace, prepare_network,
};
use crate::sandbox::RUNTIME_FILE_DESTINATIONS;
use super::runtime::{
attached_runsc_standard_io, fail_network, fail_runtime_components, fail_with_runtime_cleanup,
@ -134,6 +135,19 @@ fn prepare_state_anchor(
mount
.require_persistent_destinations(&state.persistent_destinations)
.map_err(io::Error::other)?;
let transient_targets = RUNTIME_FILE_DESTINATIONS
.iter()
.map(|(_, destination)| PathBuf::from(destination.trim_start_matches('/')))
.filter(|target| {
state
.persistent_destinations
.iter()
.any(|persistent| target.starts_with(persistent) && target != persistent)
})
.collect::<Vec<_>>();
mount
.require_transient_override_targets(&transient_targets)
.map_err(io::Error::other)?;
Ok(Some(mount))
}

View file

@ -50,7 +50,7 @@ default_allow = ["192.0.2.10/32"]
[profiles.default]
image = "example.invalid/agent:v1"
persistent_dirs = ["/root"]
persistent_dirs = ["/root", "/usr", "/var", "/etc", "/opt"]
env = {{ SELECTED_SECRET = "env:SELECTED_HOST_SECRET" }}
volumes = [
{{ host = "{{profile_dir}}/read-only", container = "/root/read-only", kind = "directory", access = "read-only", create = true }},
@ -164,6 +164,10 @@ env = {{ UNSELECTED_SECRET = "env:UNSELECTED_HOST_SECRET" }}
"runsc".to_owned(),
format!("runsc-overlay/{container}"),
"state-anchor/merged/root".to_owned(),
"state-anchor/merged/usr".to_owned(),
"state-anchor/merged/var".to_owned(),
"state-anchor/merged/etc".to_owned(),
"state-anchor/merged/opt".to_owned(),
"state-anchor/runtime-files".to_owned(),
format!("sandbox-control/{container}/credentials"),
format!("sandbox-control/{container}/mailbox/requests"),
@ -247,6 +251,15 @@ fn mounts(specification: &Value) -> BTreeMap<&str, &Value> {
.collect()
}
fn mount_destinations(specification: &Value) -> Vec<&str> {
specification["mounts"]
.as_array()
.unwrap()
.iter()
.map(|mount| mount["destination"].as_str().unwrap())
.collect()
}
fn options(mount: &Value) -> BTreeSet<&str> {
mount["options"]
.as_array()
@ -468,6 +481,25 @@ fn trusted_init_environment_is_empty_and_volume_modes_are_preserved() {
.ends_with(&format!("state-anchor/runtime-files/{name}"))
);
}
let destinations = mount_destinations(&specification);
let persistent_etc = destinations
.iter()
.position(|path| *path == "/etc")
.unwrap();
for runtime_file in [
RESOLV_CONF_DESTINATION,
HOSTS_DESTINATION,
HOSTNAME_DESTINATION,
] {
assert!(
persistent_etc
< destinations
.iter()
.position(|path| *path == runtime_file)
.unwrap()
);
}
}
#[test]

View file

@ -28,16 +28,18 @@ pub const DEV_DESTINATION: &str = "/dev";
/// # Errors
/// Returns an error if the fixed destination contract is not a closed normalized layout.
pub fn default_sandbox_layout() -> Result<SandboxLayoutV1, ConfigurationError> {
SandboxLayoutV1::from_protected_destinations([
WORKSPACE_DESTINATION.to_owned(),
CONTROL_ROOT_DESTINATION.to_owned(),
PROC_DESTINATION.to_owned(),
SYS_DESTINATION.to_owned(),
DEV_DESTINATION.to_owned(),
RESOLV_CONF_DESTINATION.to_owned(),
HOSTS_DESTINATION.to_owned(),
HOSTNAME_DESTINATION.to_owned(),
])
SandboxLayoutV1::from_destination_policy(
[
WORKSPACE_DESTINATION.to_owned(),
CONTROL_ROOT_DESTINATION.to_owned(),
PROC_DESTINATION.to_owned(),
SYS_DESTINATION.to_owned(),
DEV_DESTINATION.to_owned(),
],
RUNTIME_FILE_DESTINATIONS
.iter()
.map(|(_, destination)| (*destination).to_owned()),
)
}
#[cfg(test)]
@ -61,5 +63,14 @@ mod tests {
WORKSPACE_DESTINATION.to_owned(),
])
);
assert_eq!(
default_sandbox_layout()
.unwrap()
.nested_transient_overrides(),
&RUNTIME_FILE_DESTINATIONS
.iter()
.map(|(_, destination)| (*destination).to_owned())
.collect()
);
}
}

View file

@ -160,6 +160,7 @@ impl StateAnchorLaunchAuthority {
pub struct RuntimeStateAnchorAuthority {
lower: OwnedFd,
persistent_anchor: OwnedFd,
upper: OwnedFd,
runtime_anchor: OwnedFd,
merged_label: PathBuf,
merged_descriptor: OwnedFd,
@ -214,6 +215,7 @@ impl RuntimeStateAnchorAuthority {
inherited.persistent_anchor_label,
(retained_persistent.st_dev, retained_persistent.st_ino),
)?;
let upper = open_directory(&persistent_anchor, Path::new("upper"))?;
let runtime_root = reopen_inherited_directory(inherited.runtime_root_fd)?;
let retained_runtime_anchor = reopen_inherited_directory(inherited.runtime_anchor_fd)?;
if !inherited.runtime_anchor_label.is_absolute() {
@ -231,14 +233,13 @@ impl RuntimeStateAnchorAuthority {
inherited.runtime_anchor_label,
(retained.st_dev, retained.st_ino),
)?;
for name in ["upper", "work"] {
drop(open_directory(&persistent_anchor, Path::new(name))?);
}
drop(open_directory(&persistent_anchor, Path::new("work"))?);
let merged_descriptor = open_directory(&runtime_anchor, Path::new("merged"))?;
let merged_label = inherited.runtime_anchor_label.join("merged");
Ok(Self {
lower,
persistent_anchor,
upper,
runtime_anchor,
merged_label,
merged_descriptor,
@ -421,6 +422,54 @@ impl RuntimeStateAnchorMount {
Ok(())
}
/// Validates exact transient child targets and proves they are absent from the upper layer.
///
/// # Errors
/// Refuses changed mounts, invalid paths, non-files, links, nested mounts, or upper entries.
pub fn require_transient_override_targets(
&self,
targets: &[PathBuf],
) -> Result<(), StateAnchorError> {
if mount_identity(&self.merged)? != self.mount {
return Err(StateAnchorError::OwnershipMismatch);
}
for target in targets {
if target.as_os_str().is_empty()
|| target
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(StateAnchorError::InvalidLayout(
"transient override target is not normalized and relative",
));
}
let descriptor = openat2(
&self.merged,
target,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
ResolveFlags::BENEATH
| ResolveFlags::NO_MAGICLINKS
| ResolveFlags::NO_SYMLINKS
| ResolveFlags::NO_XDEV,
)
.map_err(io::Error::from)?;
if !FileType::from_raw_mode(
rustix::fs::fstat(&descriptor)
.map_err(io::Error::from)?
.st_mode,
)
.is_file()
{
return Err(StateAnchorError::InvalidLayout(
"transient override target is not a regular file",
));
}
require_upper_target_absent(&self.authority.upper, target)?;
}
Ok(())
}
/// Unmounts and reaps only the retained exact RS-owned State Anchor.
///
/// # Errors
@ -453,6 +502,38 @@ impl RuntimeStateAnchorMount {
}
}
fn require_upper_target_absent(upper: &OwnedFd, target: &Path) -> Result<(), StateAnchorError> {
require_upper_path_absent(upper, target)?;
let leaf = target.file_name().ok_or(StateAnchorError::InvalidLayout(
"transient override target has no final component",
))?;
let mut whiteout = std::ffi::OsString::from(".wh.");
whiteout.push(leaf);
require_upper_path_absent(
upper,
&target.parent().unwrap_or(Path::new("")).join(whiteout),
)
}
fn require_upper_path_absent(upper: &OwnedFd, target: &Path) -> Result<(), StateAnchorError> {
match openat2(
upper,
target,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
ResolveFlags::BENEATH
| ResolveFlags::NO_MAGICLINKS
| ResolveFlags::NO_SYMLINKS
| ResolveFlags::NO_XDEV,
) {
Err(rustix::io::Errno::NOENT) => Ok(()),
Ok(_) => Err(StateAnchorError::InvalidLayout(
"transient override target is present in the upper layer",
)),
Err(error) => Err(io::Error::from(error).into()),
}
}
impl VerifiedStateAnchorReadiness {
#[must_use]
pub const fn from_parts(staged: StagedProcessOwnership, mount: MountIdentity) -> Self {

View file

@ -137,7 +137,28 @@ fn run() -> io::Result<()> {
},
)
.map_err(io::Error::other)?;
let runtime_targets = [
PathBuf::from("etc/resolv.conf"),
PathBuf::from("etc/hosts"),
PathBuf::from("etc/hostname"),
];
runtime_mount
.require_persistent_destinations(&[PathBuf::from("usr"), PathBuf::from("etc")])
.map_err(io::Error::other)?;
runtime_mount
.require_transient_override_targets(&runtime_targets)
.map_err(io::Error::other)?;
fs::write(layout.merged().join("etc/hosts"), b"stale")?;
if runtime_mount
.require_transient_override_targets(&runtime_targets)
.is_ok()
{
return Err(io::Error::other(
"upper runtime-file entry survived transient override preflight",
));
}
runtime_mount.unmount().map_err(io::Error::other)?;
fs::remove_file(layout.upper().join("etc/hosts"))?;
let first = acquire(&layout, &artifact)?;
fs::write(

View file

@ -285,13 +285,15 @@ the same component-wise `0700` creation rules.
Configuration resolution also requires a closed sandbox layout. Its
closed protected-destination set contains `/workspace` and every product-owned
sandbox mount destination selected by default bundle construction. Each
persistent or volume destination is compared with every member in both ancestry
directions. The configuration loader does not guess paths for control,
credential, mailbox, proc, sys, dev, runtime, or later product mounts, and a
layout cannot be constructed without `/workspace`. Bundle
construction, rather than trusted-host configuration syntax, owns the remaining
exact destination choices.
sandbox mount destination selected by default bundle construction. Volumes and
protected trees may not overlap in either ancestry direction. Persistent paths
follow the same rule except that a bundle-classified exact transient mount may
replace a child of a persistent parent. The child remains protected and may not
itself contain or be selected as persistent state. The configuration loader does
not guess paths for control, credential, mailbox, proc, sys, dev, runtime, or
later product mounts, and a layout cannot be constructed without `/workspace`.
Bundle construction, rather than trusted-host configuration syntax, owns the
exact destination choices and transient-child classification.
The loader resolves environment references only after structural, path, and
network validation. A missing, non-UTF-8, or NUL-containing host value refuses