Restore persistent system path composition

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