diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs index 13258b10..a319d19f 100644 --- a/crates/tinybox-bus/src/grants.rs +++ b/crates/tinybox-bus/src/grants.rs @@ -49,6 +49,9 @@ pub struct ResolveLocalGrantsRequest { pub config: LocalGrantConfig, /// Host's unconditional protected-path floor. pub protected: ProtectedPaths, + /// Permit canonical non-UTF-8 Unix grant paths in `unix_paths`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_unix_paths: bool, } /// Canonical existing filesystem grants beyond a workload's writable root. @@ -58,4 +61,24 @@ pub struct LocalGrants { pub read_only: Vec, /// Read, executable, and writable paths. pub read_write: Vec, + /// Canonical Unix paths whose OS bytes are not UTF-8. + #[serde(default, skip_serializing_if = "UnixPathGrants::is_empty")] + pub unix_paths: UnixPathGrants, +} + +/// Non-UTF-8 Unix grant paths transported as their original OS bytes. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnixPathGrants { + /// Read and executable paths. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_only: Vec>, + /// Read, executable, and writable paths. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_write: Vec>, +} + +impl UnixPathGrants { + pub(crate) fn is_empty(&self) -> bool { + self.read_only.is_empty() && self.read_write.is_empty() + } } diff --git a/crates/tinybox-bus/src/jail.rs b/crates/tinybox-bus/src/jail.rs index f75e27cb..2fc13522 100644 --- a/crates/tinybox-bus/src/jail.rs +++ b/crates/tinybox-bus/src/jail.rs @@ -19,6 +19,9 @@ pub struct NativeJailConfig { pub read_only: Vec, /// Existing absolute paths additionally granted write access. pub read_write: Vec, + /// Approved non-UTF-8 Unix grant paths, represented by their OS bytes. + #[serde(default, skip_serializing_if = "crate::UnixPathGrants::is_empty")] + pub unix_paths: crate::UnixPathGrants, /// Whether outgoing network access is permitted. pub allow_net: bool, /// Whether child processes are permitted. diff --git a/crates/tinybox-bus/src/jail_tests.rs b/crates/tinybox-bus/src/jail_tests.rs index f1c52617..a95fed22 100644 --- a/crates/tinybox-bus/src/jail_tests.rs +++ b/crates/tinybox-bus/src/jail_tests.rs @@ -1,5 +1,6 @@ //! Native jail vocabulary is serialized independently of implementation types. use super::*; +use crate::{LocalGrants, UnixPathGrants}; #[test] fn native_jail_request_keeps_explicit_enforcement_and_grants() -> Result<(), serde_json::Error> { @@ -17,3 +18,42 @@ fn native_jail_request_keeps_explicit_enforcement_and_grants() -> Result<(), ser ); Ok(()) } + +#[test] +fn unix_path_fields_default_and_empty_values_keep_legacy_wire_shape() +-> Result<(), serde_json::Error> { + let value = serde_json::json!({ + "read_only": ["/usr"], "read_write": [], "allow_net": true, + "allow_subprocess": false, "enforcement": "enforced", + "require_process_isolation": true + }); + let config: NativeJailConfig = serde_json::from_value(value.clone())?; + assert!(config.unix_paths.is_empty()); + assert_eq!(serde_json::to_value(config)?, value); + let grants = UnixPathGrants::default(); + assert_eq!(serde_json::to_value(grants)?, serde_json::json!({})); + Ok(()) +} + +#[test] +fn unix_grant_bytes_round_trip_without_text_conversion() -> Result<(), serde_json::Error> { + let grants = UnixPathGrants { + read_only: vec![vec![b'/', b't', 0xff]], + read_write: vec![vec![b'/', b'w', 0xfe]], + }; + let encoded = serde_json::to_value(&grants)?; + assert_eq!(serde_json::from_value::(encoded)?, grants); + Ok(()) +} + +#[test] +fn local_grant_result_defaults_to_the_legacy_utf8_wire_shape() -> Result<(), serde_json::Error> { + let value = serde_json::json!({ + "read_only": ["/usr"], + "read_write": ["/workspace"] + }); + let grants: LocalGrants = serde_json::from_value(value.clone())?; + assert!(grants.unix_paths.is_empty()); + assert_eq!(serde_json::to_value(grants)?, value); + Ok(()) +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index abc17922..8aefd7f7 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -16,7 +16,9 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; mod grants; -pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest}; +pub use grants::{ + LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest, UnixPathGrants, +}; mod jail; pub use jail::{JailEnforcement, NativeJailConfig}; @@ -66,7 +68,8 @@ pub use files::{ /// suppression for a native shell child; ignored elsewhere). /// 1.19 adds `NetworkPolicy::Allowlist` and the observational /// [`SandboxEvent`] vocabulary (`Started`, `Denied`, `LimitHit`, `Exited`). -pub const CONTRACT_VERSION: (u32, u32) = (1, 19); +/// 1.20 adds opted-in byte-preserving Unix grant transport. +pub const CONTRACT_VERSION: (u32, u32) = (1, 20); /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index a521639f..7bd3953b 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -51,7 +51,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[29], "CleanupDocker"); assert_eq!(METHODS[30], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[34], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (1, 19)); + assert_eq!(CONTRACT_VERSION, (1, 20)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -129,8 +129,10 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (1, 19)); + assert_eq!(CONTRACT_VERSION, (1, 20)); assert!(is_compatible(CONTRACT_VERSION)); + assert!(!is_compatible((1, 19))); + assert!(!is_compatible((1, 18))); assert!(!is_compatible((1, 8))); assert!(!is_compatible((1, 7))); assert!(!is_compatible((1, 6))); @@ -147,7 +149,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[1,19],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,20],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -166,7 +168,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (1, 19)); + assert_eq!(CONTRACT_VERSION, (1, 20)); } #[test] @@ -265,11 +267,14 @@ fn local_grant_configuration_defaults_and_floor_round_trip() -> Result<(), serde prefixes: vec!["/etc".into()], credential_roots: vec!["/operator/.ssh".into()], }, + allow_unix_paths: false, }; assert_eq!( serde_json::from_value::(serde_json::to_value(&request)?)?, request ); + let wire = serde_json::to_value(&request)?; + assert_eq!(wire.get("allow_unix_paths"), None); Ok(()) } diff --git a/crates/tinybox-jail/src/grants/README.md b/crates/tinybox-jail/src/grants/README.md index d3e638cc..ac657f40 100644 --- a/crates/tinybox-jail/src/grants/README.md +++ b/crates/tinybox-jail/src/grants/README.md @@ -2,7 +2,8 @@ resolve_local_jail_grants returns canonical existing JailGrants through the workspace Result type. ResolveLocalGrants exposes the same behavior through -the module, with UTF-8 paths and an input/final 64-grant limit. +the module, with UTF-8 string paths by default. Contract 1.19 callers can opt +into raw non-UTF8 Unix path groups; all paths share the input/final 64-grant limit. mod.rs orchestrates discovery and read-only precedence; types.rs holds results. floor.rs matches components, adjacent pairs, protected prefixes and credential @@ -30,3 +31,11 @@ read-only. Mutable tools need separately approved disposable state. Hosts supply the complete protected floor and keep authorization. This module does not start commands or weaken unavailable confinement. See [the contract specification](../../../../docs/specs/local-grants.md). + +Custom existing absolute nonempty `RUSTUP_HOME` and `CARGO_HOME` directories are +also discovered. Rustup is granted read-only as a whole tree. Cargo receives only +`bin`, `config.toml`, `config`, `env`, `registry` and `git`; the Cargo root stays +excluded. Empty, relative, missing and non-directory custom roots are skipped. +Native OS paths retain their original bytes through discovery. The caller's +protected floor and canonical alias inspection apply to every custom root, and +writable extras cannot reach fixed toolchain state or Cargo roots. diff --git a/crates/tinybox-jail/src/grants/mod.rs b/crates/tinybox-jail/src/grants/mod.rs index 640a26e9..e599f4c3 100644 --- a/crates/tinybox-jail/src/grants/mod.rs +++ b/crates/tinybox-jail/src/grants/mod.rs @@ -38,16 +38,39 @@ pub fn resolve_local_jail_grants( protected: &ProtectedPaths, ) -> tinybox_core::Result { let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); - resolve_local_jail_grants_in(home, xdg_config_home.as_deref(), cfg, protected) + let rustup_home = std::env::var_os("RUSTUP_HOME"); + let cargo_home = std::env::var_os("CARGO_HOME"); + resolve_local_jail_grants_with_homes( + home, + xdg_config_home.as_deref(), + rustup_home.as_deref(), + cargo_home.as_deref(), + cfg, + protected, + ) } /// [`resolve_local_jail_grants`] with `XDG_CONFIG_HOME` passed in rather than /// read from the process environment, so callers (and tests) stay hermetic. +#[cfg(test)] pub(crate) fn resolve_local_jail_grants_in( home: Option<&Path>, xdg_config_home: Option<&std::ffi::OsStr>, cfg: &LocalGrantConfig, protected: &ProtectedPaths, +) -> tinybox_core::Result { + resolve_local_jail_grants_with_homes(home, xdg_config_home, None, None, cfg, protected) +} + +/// Resolve grants with all environment-derived roots supplied explicitly. +/// This keeps discovery testable without changing the process environment. +fn resolve_local_jail_grants_with_homes( + home: Option<&Path>, + xdg_config_home: Option<&std::ffi::OsStr>, + rustup_home: Option<&std::ffi::OsStr>, + cargo_home: Option<&std::ffi::OsStr>, + cfg: &LocalGrantConfig, + protected: &ProtectedPaths, ) -> tinybox_core::Result { let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf())); let mut b = Builder::new(home.as_deref(), protected); @@ -56,11 +79,17 @@ pub(crate) fn resolve_local_jail_grants_in( if let Some(h) = home.as_deref() { b.add_cargo_home(&h.join(".cargo")); for dir in HOME_READ_ONLY_DIRS { - b.fixed_read_only(&h.join(dir), "toolchain"); + b.toolchain_read_only(&h.join(dir), "toolchain"); } } for dir in SYSTEM_TOOLCHAIN_DIRS { - b.fixed_read_only(Path::new(dir), "toolchain"); + b.toolchain_read_only(Path::new(dir), "toolchain"); + } + if let Some(path) = existing_absolute_directory(rustup_home) { + b.toolchain_read_only(&path, "custom Rustup home"); + } + if let Some(path) = existing_absolute_directory(cargo_home) { + b.add_cargo_home(&path); } if let Some(h) = home.as_deref() { b.add_gitconfig_at(h, xdg_config_home)?; @@ -89,12 +118,18 @@ pub(crate) fn resolve_local_jail_grants_in( Ok(b.grants) } +fn existing_absolute_directory(raw: Option<&std::ffi::OsStr>) -> Option { + let path = PathBuf::from(raw?); + (!path.as_os_str().is_empty() && path.is_absolute() && path.is_dir()).then_some(path) +} + struct Builder<'a> { home: Option<&'a Path>, protected: &'a ProtectedPaths, grants: JailGrants, seen: HashSet, fixed_read_only: Vec, + cargo_roots: Vec, } impl<'a> Builder<'a> { @@ -105,6 +140,7 @@ impl<'a> Builder<'a> { grants: JailGrants::default(), seen: HashSet::new(), fixed_read_only: Vec::new(), + cargo_roots: Vec::new(), } } @@ -164,22 +200,46 @@ impl<'a> Builder<'a> { } fn read_only(&mut self, path: &Path, source: &str) { - self.add_read_only(path, source, false); + let Some(canonical) = self.admit(path, source) else { + return; + }; + if self.exposes_cargo_root(&canonical) { + log::warn!( + "refused extra grant containing the Cargo home root: {}", + path.display() + ); + return; + } + self.record_read_only(canonical); } fn fixed_read_only(&mut self, path: &Path, source: &str) { self.add_read_only(path, source, true); } + /// Reserve toolchain roots even when the floor refuses their grant, so a + /// later writable extra cannot reintroduce the same host state. + fn toolchain_read_only(&mut self, path: &Path, source: &str) { + self.fixed_read_only.push(path.to_path_buf()); + if let Ok(canonical) = path.canonicalize() { + self.fixed_read_only.push(canonical); + } + self.add_read_only(path, source, false); + } + fn add_read_only(&mut self, path: &Path, source: &str, fixed: bool) { if let Some(p) = self.admit(path, source) { if fixed { self.fixed_read_only.push(path.to_path_buf()); self.fixed_read_only.push(p.clone()); } - if self.seen.insert(p.clone()) { - self.grants.read_only.push(p); - } + self.record_read_only(p); + } + } + + fn record_read_only(&mut self, path: PathBuf) { + if self.seen.insert(path.clone()) { + self.grants.read_only.push(path); } } @@ -198,7 +258,7 @@ impl<'a> Builder<'a> { .chain(CARGO_READ_ONLY_FILES.iter().copied()) .any(|entry| reaches(&home.join(".cargo").join(entry))) }); - if exposes_fixed || exposes_cargo { + if exposes_fixed || exposes_cargo || self.exposes_cargo_root(&p) { log::warn!( "refused writable grant reaching fixed host toolchain state: {}", p.display() @@ -223,14 +283,25 @@ impl<'a> Builder<'a> { if !cargo.is_dir() { return; } + if let Ok(canonical) = cargo.canonicalize() { + self.cargo_roots.push(cargo.to_path_buf()); + self.cargo_roots.push(canonical); + } log::debug!("[sandbox:grants] granting ~/.cargo parts with host code read-only"); - self.fixed_read_only(&cargo.join("bin"), "toolchain"); - self.fixed_read_only(&cargo.join("registry"), "toolchain"); - self.fixed_read_only(&cargo.join("git"), "toolchain"); + self.toolchain_read_only(&cargo.join("bin"), "toolchain"); + self.toolchain_read_only(&cargo.join("registry"), "toolchain"); + self.toolchain_read_only(&cargo.join("git"), "toolchain"); for f in CARGO_READ_ONLY_FILES { - self.fixed_read_only(&cargo.join(f), "toolchain"); + self.toolchain_read_only(&cargo.join(f), "toolchain"); } } + + fn exposes_cargo_root(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + self.cargo_roots.iter().any(|root| { + root_spellings(root).any(|cargo_root| same_or_descendant(&cargo_root, &normalized)) + }) + } } #[cfg(test)] diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index e75dd778..28634d98 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -1121,3 +1121,281 @@ fn missing_git_include_locations_cannot_be_created_from_writable_grants() assert!(!has(&grants.read_only, &home.path().join("future/config"))); Ok(()) } +fn resolve_with_homes( + home: Option<&Path>, + rustup_home: Option<&std::ffi::OsStr>, + cargo_home: Option<&std::ffi::OsStr>, + cfg: &LocalGrantConfig, + protected: &ProtectedPaths, +) -> tinybox_core::Result { + resolve_local_jail_grants_with_homes(home, None, rustup_home, cargo_home, cfg, protected) +} + +#[test] +fn invalid_custom_toolchain_environment_roots_are_skipped() -> Result<(), Box> +{ + let home = fake_home()?; + fs::create_dir_all(home.path().join(".cargo/bin"))?; + fs::write(home.path().join(".cargo/config.toml"), "")?; + let relative_rustup = std::ffi::OsStr::new("relative-rustup"); + let missing_cargo = home.path().join("missing-cargo"); + let file_root = home.path().join("file-root"); + fs::write(&file_root, "fixture")?; + for (rustup, cargo) in [ + ( + Some(std::ffi::OsStr::new("")), + Some(std::ffi::OsStr::new("")), + ), + (Some(relative_rustup), Some(relative_rustup)), + (Some(file_root.as_os_str()), Some(file_root.as_os_str())), + ( + Some(missing_cargo.as_os_str()), + Some(missing_cargo.as_os_str()), + ), + ] { + let grants = resolve_with_homes( + Some(home.path()), + rustup, + cargo, + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + )?; + let canonical_home = home.path().canonicalize()?; + assert!( + has(&grants.read_only, &canonical_home.join(".rustup")), + "default Rustup home disappeared: {grants:?}" + ); + assert!( + has(&grants.read_only, &canonical_home.join(".cargo/bin")), + "default Cargo bin disappeared: {grants:?}" + ); + assert!( + has( + &grants.read_only, + &canonical_home.join(".cargo/config.toml") + ), + "default Cargo config disappeared: {grants:?}" + ); + assert!( + !grants + .read_only + .iter() + .any(|path| path.ends_with("relative-rustup")), + "relative root was admitted: {grants:?}" + ); + } + Ok(()) +} + +#[test] +fn custom_cargo_credentials_are_excluded_by_the_protected_floor() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let rustup = home.path().join("custom-rustup"); + let cargo = home.path().join("custom-cargo"); + fs::create_dir(&rustup)?; + fs::create_dir_all(cargo.join("bin"))?; + fs::create_dir_all(cargo.join("registry"))?; + fs::create_dir_all(cargo.join("git"))?; + fs::write(cargo.join("registry/credentials.toml"), "secret")?; + let protected = ProtectedPaths { + components: vec!["credentials.toml".into()], + ..ProtectedPaths::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + Some(rustup.as_os_str()), + Some(cargo.as_os_str()), + &LocalGrantConfig::default(), + &protected, + )?; + let cargo = cargo.canonicalize()?; + assert!( + !has(&grants.read_only, &cargo.join("registry")), + "Cargo subtree containing credentials was admitted: {grants:?}" + ); + assert!( + !grants + .read_only + .iter() + .any(|path| path.ends_with("credentials.toml")), + "credential file was granted: {grants:?}" + ); + Ok(()) +} + +#[test] +fn writable_extras_cannot_promote_custom_toolchain_roots_or_selected_cargo_state() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let rustup = home.path().join("custom-rustup"); + let cargo = home.path().join("custom-cargo"); + fs::create_dir_all(rustup.join("toolchains/stable"))?; + fs::create_dir_all(cargo.join("bin"))?; + fs::create_dir_all(cargo.join("registry/src"))?; + fs::create_dir_all(cargo.join("git"))?; + let config = LocalGrantConfig { + extra_read_only: vec![cargo.to_string_lossy().into_owned()], + extra_read_write: vec![ + home.path().to_string_lossy().into_owned(), + rustup.to_string_lossy().into_owned(), + rustup.join("toolchains").to_string_lossy().into_owned(), + cargo.to_string_lossy().into_owned(), + cargo.join("bin").to_string_lossy().into_owned(), + cargo.join("registry").to_string_lossy().into_owned(), + cargo.join("registry/src").to_string_lossy().into_owned(), + cargo.join("git").to_string_lossy().into_owned(), + ], + ..LocalGrantConfig::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + Some(rustup.as_os_str()), + Some(cargo.as_os_str()), + &config, + &ProtectedPaths::default(), + )?; + assert!( + !has(&grants.read_only, &cargo.canonicalize()?), + "whole Cargo root was granted by an extra: {grants:?}" + ); + assert!( + grants.read_write.is_empty(), + "writable extras promoted fixed toolchain state: {grants:?}" + ); + Ok(()) +} + +#[test] +fn safe_explicit_cargo_children_keep_their_requested_access() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let cargo = home.path().join("custom-cargo"); + let read_only = cargo.join("private-extension/read-only"); + let read_write = cargo.join("private-extension/read-write"); + fs::create_dir_all(cargo.join("bin"))?; + fs::create_dir_all(&read_only)?; + fs::create_dir_all(&read_write)?; + let config = LocalGrantConfig { + extra_read_only: vec![read_only.to_string_lossy().into_owned()], + extra_read_write: vec![read_write.to_string_lossy().into_owned()], + ..LocalGrantConfig::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + None, + Some(cargo.as_os_str()), + &config, + &ProtectedPaths::default(), + )?; + assert!( + has(&grants.read_only, &read_only.canonicalize()?), + "safe Cargo child read-only extra was refused: {grants:?}" + ); + assert!( + has(&grants.read_write, &read_write.canonicalize()?), + "safe Cargo child writable extra was refused: {grants:?}" + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn extra_read_only_symlink_to_whole_cargo_root_is_refused() -> Result<(), Box> +{ + let home = tempfile::tempdir()?; + let cargo = home.path().join("custom-cargo"); + let alias = home.path().join("unregistered-cargo-root-alias"); + fs::create_dir_all(cargo.join("bin"))?; + std::os::unix::fs::symlink(&cargo, &alias)?; + let config = LocalGrantConfig { + extra_read_only: vec![alias.to_string_lossy().into_owned()], + ..LocalGrantConfig::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + None, + Some(cargo.as_os_str()), + &config, + &ProtectedPaths::default(), + )?; + assert!( + !has(&grants.read_only, &cargo.canonicalize()?), + "symlink extra exposed whole Cargo root: {grants:?}" + ); + assert!( + has(&grants.read_only, &cargo.join("bin").canonicalize()?), + "fixed Cargo bin grant was lost: {grants:?}" + ); + Ok(()) +} +#[test] +#[cfg(unix)] +fn custom_toolchain_symlink_aliases_cannot_be_promoted_to_writable() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let rustup = home.path().join("custom-rustup"); + let cargo = home.path().join("custom-cargo"); + fs::create_dir_all(rustup.join("toolchains/stable"))?; + fs::create_dir_all(cargo.join("bin"))?; + std::os::unix::fs::symlink(&rustup, home.path().join("rustup-alias"))?; + std::os::unix::fs::symlink(&cargo, home.path().join("cargo-alias"))?; + let config = LocalGrantConfig { + extra_read_write: vec![ + home.path() + .join("rustup-alias/toolchains") + .to_string_lossy() + .into_owned(), + home.path() + .join("cargo-alias/bin") + .to_string_lossy() + .into_owned(), + ], + ..LocalGrantConfig::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + Some(home.path().join("rustup-alias").as_os_str()), + Some(home.path().join("cargo-alias").as_os_str()), + &config, + &ProtectedPaths::default(), + )?; + assert!( + grants.read_write.is_empty(), + "symlink alias promoted fixed toolchain state: {grants:?}" + ); + Ok(()) +} + +#[test] +fn rejected_custom_rustup_root_still_blocks_writable_descendants() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let rustup = home.path().join("custom-rustup"); + fs::create_dir_all(rustup.join("toolchains/stable"))?; + fs::write(rustup.join(".private-marker"), "protected")?; + let config = LocalGrantConfig { + extra_read_write: vec![rustup.join("toolchains").to_string_lossy().into_owned()], + ..LocalGrantConfig::default() + }; + let protected = ProtectedPaths { + components: vec![".private-marker".into()], + ..ProtectedPaths::default() + }; + let grants = resolve_with_homes( + Some(home.path()), + Some(rustup.as_os_str()), + None, + &config, + &protected, + )?; + assert!( + !has(&grants.read_only, &rustup.canonicalize()?), + "protected Rustup root was granted: {grants:?}" + ); + assert!( + grants.read_write.is_empty(), + "writable extra promoted a descendant of the rejected Rustup root: {grants:?}" + ); + Ok(()) +} diff --git a/crates/tinybox-jail/tests/toolchain_homes.rs b/crates/tinybox-jail/tests/toolchain_homes.rs new file mode 100644 index 00000000..bc49bda4 --- /dev/null +++ b/crates/tinybox-jail/tests/toolchain_homes.rs @@ -0,0 +1,227 @@ +//! Public resolver regressions for custom Rustup and Cargo homes. +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tinybox_bus::{LocalGrantConfig, ProtectedPaths}; +use tinybox_jail::grants::resolve_local_jail_grants; + +fn has(grants: &[PathBuf], path: &Path) -> bool { + grants.iter().any(|grant| grant == path) +} + +fn run_child( + test_name: &str, + rustup_home: &OsStr, + cargo_home: &OsStr, + fixture_home: &Path, + expect_omitted: bool, +) -> Result<(), Box> { + let mut command = Command::new(std::env::current_exe()?); + command + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .env("TINYBOX_CUSTOM_HOME_CHILD", "1") + .env("TINYBOX_FIXTURE_HOME", fixture_home) + .env("RUSTUP_HOME", rustup_home) + .env("CARGO_HOME", cargo_home) + .env_remove("XDG_CONFIG_HOME") + .env_remove("TINYBOX_EXPECT_HOMES_OMITTED"); + if expect_omitted { + command.env("TINYBOX_EXPECT_HOMES_OMITTED", "1"); + } + let output = command.output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "child test {test_name} failed: {stdout}\n{stderr}" + ); + assert!( + stdout.contains("running 1 test"), + "child did not execute exactly one test: {stdout}\n{stderr}" + ); + assert!( + stdout.contains(&format!("test {test_name} ... ok")), + "child test did not report success: {stdout}\n{stderr}" + ); + Ok(()) +} + +fn assert_custom_homes_are_granted() -> Result<(), Box> { + let home = + PathBuf::from(std::env::var_os("TINYBOX_FIXTURE_HOME").ok_or("missing fixture home")?); + let rustup = PathBuf::from(std::env::var_os("RUSTUP_HOME").ok_or("missing RUSTUP_HOME")?); + let cargo = PathBuf::from(std::env::var_os("CARGO_HOME").ok_or("missing CARGO_HOME")?); + let grants = resolve_local_jail_grants( + Some(&home), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + )?; + let rustup = rustup.canonicalize()?; + assert!( + has(&grants.read_only, &rustup), + "custom Rustup home was not read-only: {grants:?}" + ); + assert!( + !has(&grants.read_write, &rustup), + "custom Rustup home was writable: {grants:?}" + ); + let cargo = cargo.canonicalize()?; + for entry in ["bin", "config.toml", "env", "registry", "git"] { + let selected = cargo.join(entry); + if selected.exists() { + assert!( + has(&grants.read_only, &selected), + "custom Cargo entry {entry} was not read-only: {grants:?}" + ); + } + } + assert!( + !has(&grants.read_only, &cargo), + "whole custom Cargo root was granted: {grants:?}" + ); + assert!( + !has(&grants.read_write, &cargo), + "custom Cargo root was writable: {grants:?}" + ); + Ok(()) +} + +fn assert_custom_homes_are_omitted() -> Result<(), Box> { + let home = + PathBuf::from(std::env::var_os("TINYBOX_FIXTURE_HOME").ok_or("missing fixture home")?); + let rustup = PathBuf::from(std::env::var_os("RUSTUP_HOME").ok_or("missing RUSTUP_HOME")?); + let cargo = PathBuf::from(std::env::var_os("CARGO_HOME").ok_or("missing CARGO_HOME")?); + let grants = resolve_local_jail_grants( + Some(&home), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + )?; + let grant_reaches = |grant: &Path, root: &Path| grant == root || grant.starts_with(root); + assert!( + !grants + .read_only + .iter() + .chain(&grants.read_write) + .any(|grant| grant_reaches(grant, &rustup)), + "invalid or missing Rustup home was granted: {grants:?}" + ); + assert!( + !grants + .read_only + .iter() + .chain(&grants.read_write) + .any(|grant| grant_reaches(grant, &cargo)), + "invalid or missing Cargo home root was granted: {grants:?}" + ); + Ok(()) +} + +#[test] +fn custom_utf8_rust_homes_are_discovered_by_public_resolver() +-> Result<(), Box> { + if std::env::var_os("TINYBOX_CUSTOM_HOME_CHILD").is_some() { + return assert_custom_homes_are_granted(); + } + let fixture = tempfile::tempdir()?; + let rustup = fixture.path().join("rustup-custom"); + let cargo = fixture.path().join("cargo-custom"); + fs::create_dir_all(&rustup)?; + fs::create_dir_all(cargo.join("bin"))?; + fs::create_dir_all(cargo.join("registry"))?; + fs::create_dir_all(cargo.join("git"))?; + fs::write(cargo.join("config.toml"), "[net]\noffline = true\n")?; + fs::write(cargo.join("env"), "")?; + run_child( + "custom_utf8_rust_homes_are_discovered_by_public_resolver", + rustup.as_os_str(), + cargo.as_os_str(), + fixture.path(), + false, + ) +} + +#[test] +#[cfg(unix)] +fn custom_non_utf8_rust_homes_are_discovered_by_public_resolver() +-> Result<(), Box> { + use std::os::unix::ffi::OsStringExt; + if std::env::var_os("TINYBOX_CUSTOM_HOME_CHILD").is_some() { + if std::env::var_os("TINYBOX_EXPECT_HOMES_OMITTED").is_some() { + return assert_custom_homes_are_omitted(); + } + return assert_custom_homes_are_granted(); + } + let fixture = tempfile::tempdir()?; + let rustup = fixture + .path() + .join(std::ffi::OsString::from_vec(b"rustup-\xff".to_vec())); + let cargo = fixture + .path() + .join(std::ffi::OsString::from_vec(b"cargo-\xfe".to_vec())); + #[cfg(target_os = "macos")] + { + let Err(error) = fs::create_dir_all(&rustup) else { + return Err("APFS accepted an invalid UTF-8 Rustup path".into()); + }; + assert_eq!( + error.raw_os_error(), + Some(92), + "unexpected APFS error: {error}" + ); + let Err(error) = fs::create_dir_all(cargo.join("bin")) else { + return Err("APFS accepted an invalid UTF-8 Cargo path".into()); + }; + assert_eq!( + error.raw_os_error(), + Some(92), + "unexpected APFS error: {error}" + ); + run_child( + "custom_non_utf8_rust_homes_are_discovered_by_public_resolver", + rustup.as_os_str(), + cargo.as_os_str(), + fixture.path(), + true, + ) + } + #[cfg(not(target_os = "macos"))] + { + fs::create_dir_all(&rustup)?; + fs::create_dir_all(cargo.join("bin"))?; + fs::create_dir_all(cargo.join("registry"))?; + fs::create_dir_all(cargo.join("git"))?; + fs::write(cargo.join("config.toml"), "[net]\noffline = true\n")?; + fs::write(cargo.join("env"), "")?; + run_child( + "custom_non_utf8_rust_homes_are_discovered_by_public_resolver", + rustup.as_os_str(), + cargo.as_os_str(), + fixture.path(), + false, + ) + } +} + +#[test] +fn missing_custom_rust_homes_are_omitted_by_public_resolver() +-> Result<(), Box> { + const TEST_NAME: &str = "missing_custom_rust_homes_are_omitted_by_public_resolver"; + if std::env::var_os("TINYBOX_CUSTOM_HOME_CHILD").is_some() { + return assert_custom_homes_are_omitted(); + } + let fixture = tempfile::tempdir()?; + let rustup = fixture.path().join("missing-rustup"); + let cargo = fixture.path().join("missing-cargo"); + assert!(!rustup.exists()); + assert!(!cargo.exists()); + run_child( + TEST_NAME, + rustup.as_os_str(), + cargo.as_os_str(), + fixture.path(), + true, + ) +} diff --git a/crates/tinybox-module/examples/verify_module.rs b/crates/tinybox-module/examples/verify_module.rs index 9c71ac1f..57422d5c 100644 --- a/crates/tinybox-module/examples/verify_module.rs +++ b/crates/tinybox-module/examples/verify_module.rs @@ -163,6 +163,7 @@ async fn verify_local_grants(proxy: &tinybus::Proxy) -> Result<(), Box DockerPolicyReport`, the standalone form of the 1.17 check: it lists every way a run's network mode, workspace and read-only mounts would weaken isolation, without a daemon or a diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index fd2e7257..92a07dd9 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -61,16 +61,43 @@ impl BoxService { } } -/// Serialize resolved grant paths without replacing invalid UTF-8 bytes. -fn grant_paths_to_wire(paths: Vec) -> TinyBusResult> { - paths - .into_iter() - .map(|path| { - path.into_os_string().into_string().map_err(|_| { - resources::failure(tinybox_bus::INVALID_FILE_PATH, "grant path is not UTF-8") - }) - }) - .collect() +/// Split resolved paths while preserving the legacy UTF-8 refusal by default. +fn grant_paths_to_wire( + paths: Vec, + allow_unix_paths: bool, +) -> TinyBusResult<(Vec, Vec>)> { + let mut utf8 = Vec::with_capacity(paths.len()); + #[cfg(unix)] + let mut unix = Vec::new(); + #[cfg(not(unix))] + let unix = Vec::new(); + for path in paths { + match path.into_os_string().into_string() { + Ok(path) => utf8.push(path), + Err(path) if allow_unix_paths => { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + unix.push(path.into_vec()); + } + #[cfg(not(unix))] + { + drop(path); + return Err(resources::failure( + tinybox_bus::INVALID_FILE_PATH, + "non-UTF-8 grant paths require Unix", + )); + } + } + Err(_) => { + return Err(resources::failure( + tinybox_bus::INVALID_FILE_PATH, + "grant path is not UTF-8", + )); + } + } + } + Ok((utf8, unix)) } #[tinybus::interface(name = "ai.tinyhumans.tinybox.Box")] @@ -102,6 +129,7 @@ impl BoxService { "too many local grant paths", )); } + let allow_unix_paths = request.allow_unix_paths; let grants = tokio::task::spawn_blocking(move || { tinybox_jail::grants::resolve_local_jail_grants( request.home.as_deref().map(std::path::Path::new), @@ -125,9 +153,16 @@ impl BoxService { "too many discovered local grant paths", )); } + let (read_only, unix_read_only) = grant_paths_to_wire(grants.read_only, allow_unix_paths)?; + let (read_write, unix_read_write) = + grant_paths_to_wire(grants.read_write, allow_unix_paths)?; Ok(tinybox_bus::LocalGrants { - read_only: grant_paths_to_wire(grants.read_only)?, - read_write: grant_paths_to_wire(grants.read_write)?, + read_only, + read_write, + unix_paths: tinybox_bus::UnixPathGrants { + read_only: unix_read_only, + read_write: unix_read_write, + }, }) } /// Report native jail enforcement facts without starting a workload. diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index f6ca1cc1..d09c484b 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -242,6 +242,7 @@ async fn new_module_operations_dispatch_and_preserve_validation_errors() -> tiny tinybox_bus::NativeJailConfig { read_only: Vec::new(), read_write: Vec::new(), + unix_paths: tinybox_bus::UnixPathGrants::default(), allow_net: false, allow_subprocess: false, enforcement: tinybox_bus::JailEnforcement::Declared, @@ -923,6 +924,7 @@ async fn local_grant_discovery_refuses_home_ancestors_of_credentials() ], ..tinybox_bus::ProtectedPaths::default() }, + allow_unix_paths: false, }) .await?; assert_eq!( @@ -949,6 +951,7 @@ async fn local_grant_discovery_rejects_excessive_requested_grants() { ..tinybox_bus::LocalGrantConfig::default() }, protected: tinybox_bus::ProtectedPaths::default(), + allow_unix_paths: false, }) .await; assert!(result.is_err()); @@ -962,13 +965,25 @@ fn local_grant_discovery_rejects_non_utf8_canonical_targets() // APFS refuses invalid UTF-8 filenames. Supply the canonical path directly // at the serialization boundary instead of depending on filesystem encoding. let target = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![b'/', b'd', 0xff])); - let error = super::grant_paths_to_wire(vec!["/valid".into(), target]) + let error = super::grant_paths_to_wire(vec!["/valid".into(), target], false) .err() .ok_or("non-UTF8 grant must be rejected")?; assert_eq!(error.wire_name(), tinybox_bus::INVALID_FILE_PATH); Ok(()) } +#[test] +#[cfg(unix)] +fn opted_in_local_grants_keep_non_utf8_paths_as_raw_bytes() -> Result<(), Box> +{ + use std::os::unix::ffi::OsStringExt; + let target = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![b'/', b'd', 0xff])); + let (utf8, unix) = super::grant_paths_to_wire(vec!["/valid".into(), target], true)?; + assert_eq!(utf8, vec!["/valid"]); + assert_eq!(unix, vec![vec![b'/', b'd', 0xff]]); + Ok(()) +} + #[tokio::test] async fn local_grant_discovery_bounds_final_toolchain_and_extra_grants() -> Result<(), Box> { @@ -988,6 +1003,7 @@ async fn local_grant_discovery_bounds_final_toolchain_and_extra_grants() ..tinybox_bus::LocalGrantConfig::default() }, protected: tinybox_bus::ProtectedPaths::default(), + allow_unix_paths: false, }) .await; assert!( @@ -1015,6 +1031,7 @@ async fn local_grant_discovery_accepts_exactly_the_final_limit() ..tinybox_bus::LocalGrantConfig::default() }, protected: tinybox_bus::ProtectedPaths::default(), + allow_unix_paths: false, }) .await?; assert_eq!(result.read_only.len(), tinybox_bus::MAX_NATIVE_JAIL_GRANTS); @@ -1036,6 +1053,7 @@ async fn unsupported_git_include_expansions_refuse_the_whole_discovery() home: Some(home.path().to_str().ok_or("non-UTF8 fixture")?.into()), config: tinybox_bus::LocalGrantConfig::default(), protected: tinybox_bus::ProtectedPaths::default(), + allow_unix_paths: false, }) .await; assert!( diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 86a33d9c..f63255f2 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -19,7 +19,12 @@ pub(super) fn prepare( if config.require_process_isolation && !backend.is_suitable_for_untrusted_code() { return Err(unavailable()); } - if config.read_only.len() + config.read_write.len() > tinybox_bus::MAX_NATIVE_JAIL_GRANTS { + if config.read_only.len() + + config.read_write.len() + + config.unix_paths.read_only.len() + + config.unix_paths.read_write.len() + > tinybox_bus::MAX_NATIVE_JAIL_GRANTS + { return Err(invalid()); } let root = root.canonicalize().map_err(|_| invalid())?; @@ -39,6 +44,10 @@ pub(super) fn prepare( jail.allow_subprocess = config.allow_subprocess; jail.read_only = grants(&config.read_only)?; jail.read_write = grants(&config.read_write)?; + jail.read_only + .extend(unix_grants(&config.unix_paths.read_only)?); + jail.read_write + .extend(unix_grants(&config.unix_paths.read_write)?); match config.enforcement { JailEnforcement::Enforced => backend.require(&jail).map_err(|_| unavailable())?, JailEnforcement::Declared => { @@ -54,6 +63,36 @@ pub(super) fn prepare( } Ok(jail) } + +fn unix_grants(paths: &[Vec]) -> Result> { + if paths.is_empty() { + return Ok(Vec::new()); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + paths + .iter() + .map(|bytes| { + let path = std::path::PathBuf::from(std::ffi::OsString::from_vec(bytes.clone())); + if bytes.len() > tinybox_bus::MAX_FILE_PATH_BYTES + || bytes.contains(&0) + || !path.is_absolute() + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(invalid()); + } + path.canonicalize().map_err(|_| invalid()) + }) + .collect() + } + #[cfg(not(unix))] + { + Err(unavailable()) + } +} fn grants(paths: &[String]) -> Result> { paths .iter() diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index 577d1c4a..b19a46ce 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -39,6 +39,7 @@ fn config() -> NativeJailConfig { NativeJailConfig { read_only: vec![], read_write: vec![], + unix_paths: tinybox_bus::UnixPathGrants::default(), allow_net: false, allow_subprocess: false, enforcement: JailEnforcement::Enforced, @@ -57,6 +58,82 @@ fn native_grants_reject_relative_parent_null_and_missing_paths() { assert!(grants(&[path.into()]).is_err()); } } + +#[cfg(unix)] +#[test] +fn unix_grants_reject_malformed_and_missing_byte_paths() { + for path in [ + vec![b'r', b'e', b'l'], + vec![b'/', b'a', b'/', b'.', b'.', b'/', b'b'], + vec![b'/', b'a', 0, b'b'], + vec![b'/'; tinybox_bus::MAX_FILE_PATH_BYTES + 1], + b"/tinybox-missing-byte-grant".to_vec(), + ] { + assert!(unix_grants(&[path]).is_err()); + } +} + +#[cfg(unix)] +#[test] +fn opted_in_unix_grants_preserve_path_bytes_and_validate_canonical_targets() +-> std::result::Result<(), Box> { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let root = tempfile::tempdir()?; + let directory = root.path().join("文件"); + std::fs::create_dir(&directory)?; + let bytes = directory.as_os_str().as_bytes().to_vec(); + let decoded = unix_grants(std::slice::from_ref(&bytes))?; + assert_eq!(decoded, vec![directory.canonicalize()?]); + + let invalid_path = root + .path() + .join(std::ffi::OsString::from_vec(vec![b'f', 0xff])); + #[cfg(target_os = "macos")] + { + let Err(error) = std::fs::write(&invalid_path, b"data") else { + return Err("APFS accepted an invalid UTF-8 filename".into()); + }; + assert_eq!(error.raw_os_error(), Some(92)); + let invalid_bytes = invalid_path.as_os_str().as_bytes().to_vec(); + assert!(unix_grants(std::slice::from_ref(&invalid_bytes)).is_err()); + } + #[cfg(not(target_os = "macos"))] + { + std::fs::write(&invalid_path, b"data")?; + let invalid_bytes = invalid_path.as_os_str().as_bytes().to_vec(); + let decoded = unix_grants(std::slice::from_ref(&invalid_bytes))?; + assert_eq!(decoded, vec![invalid_path.canonicalize()?]); + } + Ok(()) +} + +#[cfg(unix)] +#[test] +fn native_jail_counts_utf8_and_byte_grants_together() { + let mut config = config(); + config.read_only = vec!["/".into(); tinybox_bus::MAX_NATIVE_JAIL_GRANTS]; + config.unix_paths.read_write = vec![b"/".to_vec()]; + assert!( + prepare( + std::path::Path::new("/"), + &config, + &tinybox_core::ExecRequest::new(["irrelevant"]), + &Backend { + available: true, + suitable: true, + name: "fixture", + full: true, + }, + ) + .is_err() + ); +} + +#[cfg(not(unix))] +#[test] +fn non_unix_platform_refuses_nonempty_byte_grants() { + assert!(unix_grants(&[b"/tmp".to_vec()]).is_err()); +} #[test] fn unavailable_and_noop_backends_never_accept_declared_enforcement() -> std::result::Result<(), Box> { @@ -80,6 +157,7 @@ fn unavailable_and_noop_backends_never_accept_declared_enforcement() #[test] fn approved_grants_preserve_flags_and_preflight_refuses_weaker_facts() -> std::result::Result<(), Box> { + use std::os::unix::ffi::OsStrExt; let root = tempfile::tempdir()?; let extra = tempfile::tempdir()?; let file = root.path().join("file"); @@ -101,6 +179,11 @@ fn approved_grants_preserve_flags_and_preflight_refuses_weaker_facts() assert_eq!(jail.root, root.path().canonicalize()?); assert_eq!(jail.read_only, vec![file.canonicalize()?]); assert_eq!(jail.read_write, vec![extra.path().canonicalize()?]); + let byte_file = root.path().join("字节"); + std::fs::write(&byte_file, b"bytes")?; + config.unix_paths.read_only = vec![byte_file.as_os_str().as_bytes().to_vec()]; + let jail = prepare(root.path(), &config, &command, &backend)?; + assert!(jail.read_only.contains(&byte_file.canonicalize()?)); assert!(!jail.allow_net && !jail.allow_subprocess); config.require_process_isolation = true; assert!(prepare(root.path(), &config, &command, &backend).is_err()); diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 33e1b9a2..e09274ce 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -32,3 +32,4 @@ before scheduling blocking work. Pending preparations and retained journals share the module admission budget. Close fences the resource and stops owned work before reporting failed scratch deletion. The sanitized child environment includes resource variables, with explicit shell-request overrides applied last. +On Unix, nonempty RUSTUP_HOME and CARGO_HOME values are snapshotted as OS values, preserving non-UTF-8 path bytes. Empty values are treated as unset. Explicit resource and shell-request overrides retain their existing precedence. The snapshot carries only these two names as additional host values. diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 6943cd56..854a8d4a 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -62,7 +62,7 @@ impl ShellContext { .tempdir_in(scratch_root) .map_err(|_| invalid())?; let selection = tinybox_host::select_shell(&request.shell); - let mut environment = std::collections::BTreeMap::new(); + let mut environment = std::collections::BTreeMap::::new(); for name in [ "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR", ] @@ -73,9 +73,10 @@ impl ShellContext { if value.is_empty() { return Err(invalid()); } - environment.insert(name.to_owned(), value); + environment.insert(name.to_owned(), value.into()); } } + snapshot_toolchain_homes(&mut environment); if request.shell.python_utf8 { apply_python_utf8_defaults(&mut environment); } @@ -86,7 +87,7 @@ impl ShellContext { if cfg!(windows) { environment.retain(|key, _| !key.eq_ignore_ascii_case(name)); } - environment.insert(name.clone(), value.clone()); + environment.insert(name.clone(), value.clone().into()); } for name in ["TMPDIR", "TEMP", "TMP"] { if !resolved @@ -229,6 +230,17 @@ impl ShellContext { })? } } +fn snapshot_toolchain_homes( + environment: &mut std::collections::BTreeMap, +) { + for name in ["RUSTUP_HOME", "CARGO_HOME"] { + if let Some(value) = std::env::var_os(name) + && !value.is_empty() + { + environment.insert(name.to_owned(), value); + } + } +} /// Windows: `CREATE_NO_WINDOW`, so a GUI host's shell child flashes no console. /// Other platforms have no console window to hide. fn apply_window_flags(command: &mut std::process::Command, hide_window: bool) { @@ -269,11 +281,13 @@ mod tests; // Parent value wins; otherwise default to UTF-8 so a legacy Windows console // code page cannot kill a Python script. -fn apply_python_utf8_defaults(environment: &mut std::collections::BTreeMap) { +fn apply_python_utf8_defaults( + environment: &mut std::collections::BTreeMap, +) { for (name, default) in [("PYTHONUTF8", "1"), ("PYTHONIOENCODING", "utf-8")] { let value = std::env::var_os(name) .and_then(|v| v.into_string().ok()) .unwrap_or_else(|| default.into()); - environment.insert(name.to_owned(), value); + environment.insert(name.to_owned(), value.into()); } } diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 038a3370..0ed9654c 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -1,5 +1,18 @@ //! Validation and environment behavior before process admission. use super::*; +trait EnvironmentText { + fn environment_text(&self) -> Option<&str>; +} +impl EnvironmentText for String { + fn environment_text(&self) -> Option<&str> { + Some(self.as_str()) + } +} +impl EnvironmentText for std::ffi::OsString { + fn environment_text(&self) -> Option<&str> { + self.to_str() + } +} fn request(state: &Path) -> ShellExecRequest { ShellExecRequest { process: tinybox_bus::ResourceId("p".into()), @@ -64,7 +77,10 @@ fn approved_environment_overrides_preserve_custom_temporary_directory_and_remove .insert("SHELLOPTS".into(), "errexit".into()); let shell = ShellContext::prepare(request, state.path(), &resolved)?; assert_eq!( - shell.environment.get("TMPDIR").map(String::as_str), + shell + .environment + .get("TMPDIR") + .and_then(EnvironmentText::environment_text), Some("approved-temporary-location") ); #[cfg(unix)] @@ -125,6 +141,7 @@ async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraint request.jail = Some(tinybox_bus::NativeJailConfig { read_only: vec![], read_write: vec![], + unix_paths: tinybox_bus::UnixPathGrants::default(), allow_net: false, allow_subprocess: false, enforcement: tinybox_bus::JailEnforcement::Enforced, @@ -256,20 +273,139 @@ fn native_shell_preserves_resource_environment_and_request_overrides() .insert("OVERRIDE".into(), "request".into()); let shell = ShellContext::prepare(request, state.path(), &resolved)?; assert_eq!( - shell.environment.get("RESOURCE_CONFIG").map(String::as_str), + shell + .environment + .get("RESOURCE_CONFIG") + .and_then(EnvironmentText::environment_text), Some("preserved") ); assert_eq!( - shell.environment.get("TMPDIR").map(String::as_str), + shell + .environment + .get("TMPDIR") + .and_then(EnvironmentText::environment_text), Some("resource-temp") ); assert_eq!( - shell.environment.get("OVERRIDE").map(String::as_str), + shell + .environment + .get("OVERRIDE") + .and_then(EnvironmentText::environment_text), Some("request") ); Ok(()) } +#[cfg(unix)] +fn toolchain_environment_child( + mode: &str, + rustup: std::ffi::OsString, + cargo: std::ffi::OsString, +) -> std::io::Result { + let mut command = std::process::Command::new(std::env::current_exe()?); + command + .args([ + "unix_toolchain_environment_is_preserved_for_the_shell_child", + "--nocapture", + ]) + .env_clear() + .env("TINYBOX_NATIVE_ENV_TEST", mode) + .env("TINYBOX_UNAPPROVED", "ambient-secret") + .env("RUSTUP_HOME", rustup) + .env("CARGO_HOME", cargo); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + if let Some(profile_file) = std::env::var_os("LLVM_PROFILE_FILE") { + command.env("LLVM_PROFILE_FILE", profile_file); + } + command.output() +} +#[cfg(unix)] +#[test] +fn unix_toolchain_environment_is_preserved_for_the_shell_child() +-> std::result::Result<(), Box> { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + if let Some(mode) = std::env::var_os("TINYBOX_NATIVE_ENV_TEST") { + let state = tempfile::tempdir()?; + let mut request = request(state.path()); + request.shell.command = "printf '%s|%s|%s' \"${RUSTUP_HOME-unset}\" \"${CARGO_HOME-unset}\" \"${TINYBOX_UNAPPROVED-unset}\"".into(); + let mut resolved = tinybox_core::ExecRequest::new(["unused"]); + let expected = match mode.to_str() { + Some("raw") => { + let rustup = std::env::var_os("RUSTUP_HOME") + .ok_or_else(|| std::io::Error::other("child fixture is missing RUSTUP_HOME"))?; + let cargo = std::env::var_os("CARGO_HOME") + .ok_or_else(|| std::io::Error::other("child fixture is missing CARGO_HOME"))?; + [rustup.as_bytes(), b"|", cargo.as_bytes(), b"|unset"].concat() + } + Some("overrides") => { + resolved + .env + .insert("RUSTUP_HOME".into(), "resource-rustup".into()); + resolved + .env + .insert("CARGO_HOME".into(), "resource-cargo".into()); + request + .extra_env + .insert("RUSTUP_HOME".into(), "request-rustup".into()); + request + .extra_env + .insert("CARGO_HOME".into(), "request-cargo".into()); + b"request-rustup|request-cargo|unset".to_vec() + } + Some("empty") => b"unset|unset|unset".to_vec(), + _ => return Err("unknown native shell child fixture mode".into()), + }; + assert!(!request.extra_env.contains_key("TINYBOX_UNAPPROVED")); + assert!(std::env::var_os("TINYBOX_UNAPPROVED").is_some()); + let shell = ShellContext::prepare(request, state.path(), &resolved)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let output = runtime.block_on(async { + let output = shell + .run( + &resolved, + Arc::new(super::super::output::OutputJournal::default()), + ) + .await?; + shell.drain().await?; + Ok::<_, tinybox_core::Error>(output) + })?; + assert_eq!(output.exit_code, 0); + assert_eq!(output.stdout, expected); + return Ok(()); + } + + for (mode, rustup, cargo) in [ + ( + "raw", + std::ffi::OsString::from_vec(b"/tmp/rustup-\xff".to_vec()), + std::ffi::OsString::from_vec(b"/tmp/cargo-\xfe".to_vec()), + ), + ( + "overrides", + "/tmp/default-rustup".into(), + "/tmp/default-cargo".into(), + ), + ("empty", "".into(), "".into()), + ] { + let output = toolchain_environment_child(mode, rustup, cargo)?; + assert!( + String::from_utf8_lossy(&output.stdout).contains("running 1 test"), + "native shell child fixture {mode} did not run exactly one matching test: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + output.status.success(), + "native shell child fixture {mode} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} #[test] fn python_utf8_defaults_apply_only_when_requested_and_yield_to_approved_overrides() -> std::result::Result<(), Box> { @@ -288,7 +424,7 @@ fn python_utf8_defaults_apply_only_when_requested_and_yield_to_approved_override shell .environment .get("PYTHONIOENCODING") - .map(String::as_str), + .and_then(EnvironmentText::environment_text), Some("latin-1") ); Ok(()) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs index e2c6c408..08a118b7 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -2,7 +2,7 @@ use tinybox_bus::ShellExecRequest; pub(in crate::tinybus_module) struct ShellContext { pub(super) request: ShellExecRequest, - pub(super) environment: std::collections::BTreeMap, + pub(super) environment: std::collections::BTreeMap, pub(super) scratch: std::sync::Arc>>, pub(super) scratch_cleanup_failed: std::sync::Arc, pub(super) host: ShellHost, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 3b11b765..09740e85 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -283,6 +283,7 @@ async fn jailed_start_rejects_missing_grants_without_consuming_reservation() -> let config = tinybox_bus::NativeJailConfig { read_only: vec!["/tinybox-nonexistent-grant-fixture".into()], read_write: vec![], + unix_paths: tinybox_bus::UnixPathGrants::default(), allow_net: true, allow_subprocess: true, enforcement: tinybox_bus::JailEnforcement::Declared, diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 44621628..05147fb4 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -79,6 +79,7 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Result<(), Box = Result>; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compiled_module_discovers_and_runs_custom_rust_homes() -> TestResult<()> { + if let Some(mode) = std::env::var_os(CHILD_MODE) { + return run_module_child(mode.to_str().ok_or("child mode is not UTF-8")?).await; + } + + // This dynamic module build finishes before any child receives custom Cargo homes. + let artifact = compiled_artifact()?; + let utf8 = Fixture::new("utf8", None)?; + run_child(&utf8, &artifact, "utf8", None)?; + + #[cfg(all(unix, not(target_os = "macos")))] + { + let bytes = Fixture::new("bytes", None)?; + run_child(&bytes, &artifact, "bytes", None)?; + } + + #[cfg(target_os = "macos")] + { + let mac_bytes = Fixture::new("mac-bytes", None)?; + assert_macos_rejects_invalid_home_paths(&mac_bytes)?; + run_child(&mac_bytes, &artifact, "mac-bytes", None)?; + } + + #[cfg(target_os = "linux")] + { + let (rustup_home, rustup_proxy, toolchain) = installed_toolchain()?; + let local_tools = Fixture::new("local-tools", Some(&rustup_home))?; + local_tools.install_rustup_proxies(&rustup_proxy)?; + run_child(&local_tools, &artifact, "local-tools", Some(&toolchain))?; + + let local_bytes = Fixture::new("local-bytes", None)?; + run_child(&local_bytes, &artifact, "local-bytes", Some(&toolchain))?; + } + + Ok(()) +} + +fn run_child( + fixture: &Fixture, + artifact: &Path, + mode: &str, + toolchain: Option<&str>, +) -> TestResult<()> { + let mut child = Command::new(std::env::current_exe()?); + child.args([TEST_NAME, "--nocapture"]).env_clear(); + + for name in [ + "PATH", + "LD_LIBRARY_PATH", + "LLVM_PROFILE_FILE", + "SystemRoot", + "WINDIR", + "COMSPEC", + "PATHEXT", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "TEMP", + "TMP", + ] { + if let Some(value) = std::env::var_os(name) { + child.env(name, value); + } + } + + child + .env(CHILD_MODE, mode) + .env("TINYBOX_E2E_ARTIFACT", artifact) + .env("TINYBOX_E2E_HOME", &fixture.home) + .env("TINYBOX_E2E_WORKSPACE", &fixture.workspace) + .env("TINYBOX_E2E_STATE", &fixture.state) + .env("TINYBOX_E2E_OUTSIDE", &fixture.outside_sentinel) + .env("RUSTUP_HOME", &fixture.rustup_home) + .env("CARGO_HOME", &fixture.cargo_home); + if let Some(toolchain) = toolchain { + child.env("TINYBOX_SELECTED_TOOLCHAIN", toolchain); + } + + let output = child.output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("running 1 test"), + "child did not run exactly one matching test for {mode}: {stdout}\n{stderr}" + ); + assert!( + stdout.contains(&format!("test {TEST_NAME} ... ok")), + "child test result was absent for {mode}: {stdout}\n{stderr}" + ); + assert!( + output.status.success(), + "compiled-module child failed for {mode}: {stdout}\n{stderr}" + ); + Ok(()) +} + +struct Fixture { + _root: tempfile::TempDir, + rustup_home: PathBuf, + cargo_home: PathBuf, + home: PathBuf, + workspace: PathBuf, + state: PathBuf, + outside_sentinel: PathBuf, +} + +#[cfg(target_os = "macos")] +fn assert_macos_rejects_invalid_home_paths(fixture: &Fixture) -> TestResult<()> { + for path in [&fixture.rustup_home, &fixture.cargo_home] { + let Err(error) = fs::create_dir_all(path) else { + return Err("macOS fixture unexpectedly created an invalid-byte home".into()); + }; + assert_eq!( + error.raw_os_error(), + Some(92), + "unexpected macOS path error: {error}" + ); + } + Ok(()) +} +impl Fixture { + fn new(kind: &str, rustup_target: Option<&Path>) -> TestResult { + let root = tempfile::tempdir()?; + let (rustup_leaf, cargo_leaf) = match kind { + "utf8" => ( + OsString::from("rustup-用户"), + OsString::from("cargo-данные"), + ), + #[cfg(all(unix, not(target_os = "macos")))] + "bytes" => (unix_name(b"rustup-\xff"), unix_name(b"cargo-\xfe")), + #[cfg(target_os = "macos")] + "mac-bytes" => (unix_name(b"rustup-\xff"), unix_name(b"cargo-\xfe")), + #[cfg(unix)] + "local-tools" => (OsString::from("rustup-работа"), unix_name(b"cargo-\xfc")), + #[cfg(unix)] + "local-bytes" => (unix_name(b"rustup-\xfd"), unix_name(b"cargo-\xfc")), + _ => return Err("unknown Rust homes fixture".into()), + }; + let rustup_home = root.path().join(rustup_leaf); + let cargo_home = root.path().join(cargo_leaf); + let create_homes = kind != "mac-bytes"; + if let Some(target) = rustup_target { + #[cfg(unix)] + std::os::unix::fs::symlink(target, &rustup_home)?; + #[cfg(not(unix))] + return Err("Rustup home symlink fixture requires Unix".into()); + } else if create_homes { + fs::create_dir_all(&rustup_home)?; + fs::write(rustup_home.join("settings.toml"), "version = 12\n")?; + fs::create_dir_all(rustup_home.join("toolchains"))?; + } + if create_homes { + fs::create_dir_all(cargo_home.join("bin"))?; + fs::create_dir_all(cargo_home.join("registry"))?; + fs::create_dir_all(cargo_home.join("git"))?; + fs::write(cargo_home.join("config.toml"), "[net]\noffline = true\n")?; + fs::write(cargo_home.join("env"), "TINYBOX_FIXTURE=1\n")?; + fs::write(cargo_home.join("registry/cache-fixture"), "cache marker\n")?; + fs::write(cargo_home.join("git/cache-fixture"), "git marker\n")?; + fs::write( + cargo_home.join("credentials.toml"), + "credential fixture marker\n", + )?; + } + + let home = root.path().join("operator-home"); + let workspace = root.path().join("workspace"); + let state = root.path().join("state"); + let temp = root.path().join("temporary"); + fs::create_dir_all(&home)?; + fs::create_dir_all(&workspace)?; + fs::create_dir_all(&state)?; + fs::create_dir_all(temp)?; + let outside_sentinel = root.path().join("outside-sentinel"); + fs::write(&outside_sentinel, "outside fixture marker\n")?; + + Ok(Self { + _root: root, + rustup_home, + cargo_home, + home, + workspace, + state, + outside_sentinel, + }) + } + + #[cfg(target_os = "linux")] + fn install_rustup_proxies(&self, rustup: &Path) -> TestResult<()> { + for name in ["rustup", "cargo", "rustc"] { + fs::copy(rustup, self.cargo_home.join("bin").join(name))?; + } + Ok(()) + } +} + +#[cfg(unix)] +fn unix_name(bytes: &[u8]) -> OsString { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(bytes.to_vec()) +} + +#[cfg(target_os = "linux")] +fn installed_toolchain() -> TestResult<(PathBuf, PathBuf, String)> { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or("host home is unavailable before child environment seeding")?; + let rustup_home = + std::env::var_os("RUSTUP_HOME").map_or_else(|| home.join(".rustup"), PathBuf::from); + if !rustup_home.join("settings.toml").is_file() || !rustup_home.join("toolchains").is_dir() { + return Err("installed Rustup home or toolchains are unavailable".into()); + } + + let cargo_home = + std::env::var_os("CARGO_HOME").map_or_else(|| home.join(".cargo"), PathBuf::from); + let mut candidates = vec![cargo_home.join("bin/rustup")]; + if let Some(path) = std::env::var_os("PATH") { + candidates.extend(std::env::split_paths(&path).map(|directory| directory.join("rustup"))); + } + let rustup = candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or("installed Rustup multicall executable was not found")? + .canonicalize()?; + let status = Command::new(&rustup) + .args(["show", "active-toolchain"]) + .output()?; + if !status.status.success() { + return Err(format!( + "installed rustup could not identify the active toolchain: {}", + String::from_utf8_lossy(&status.stderr) + ) + .into()); + } + let toolchain = String::from_utf8(status.stdout)? + .split_whitespace() + .next() + .ok_or("installed rustup reported no active toolchain")? + .to_owned(); + Ok((rustup_home.canonicalize()?, rustup, toolchain)) +} + +async fn wait_for_module_registration(client: &Connection) -> TestResult<()> { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if client + .list_names() + .await? + .iter() + .any(|name| name.as_str() == tinybox_bus::INTERFACE) + { + return Ok::<(), tinybus::Error>(()); + } + tokio::task::yield_now().await; + } + }) + .await??; + Ok(()) +} +async fn run_module_child(mode: &str) -> TestResult<()> { + let artifact = env_path("TINYBOX_E2E_ARTIFACT")?; + let home = env_path("TINYBOX_E2E_HOME")?; + let workspace = env_path("TINYBOX_E2E_WORKSPACE")?; + let state = env_path("TINYBOX_E2E_STATE")?; + #[cfg(target_os = "linux")] + let outside = env_path("TINYBOX_E2E_OUTSIDE")?; + let rustup_home = env_path("RUSTUP_HOME")?; + let cargo_home = env_path("CARGO_HOME")?; + + // Stage the production artifact through the same admission path as the existing proof. + let staged = tempfile::Builder::new() + .prefix("rust-homes-module-") + .tempdir_in(artifact.parent().ok_or("compiled artifact has no parent")?)?; + #[cfg(windows)] + secure_staging_directory(staged.path())?; + let staged_artifact = staged.path().join( + artifact + .file_name() + .ok_or("compiled artifact has no filename")?, + ); + fs::copy(&artifact, &staged_artifact)?; + + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + let loaded = modules.load_file(&staged_artifact)?; + let declared: Vec<_> = loaded + .manifest + .provides + .iter() + .flat_map(|interface| interface.methods.iter()) + .map(tinybus::MemberName::as_str) + .collect(); + assert_eq!(declared, tinybox_bus::METHODS); + + let client = Connection::connect(bus.connect().await?).await?; + wait_for_module_registration(&client).await?; + let proxy = client.proxy( + tinybox_bus::INTERFACE, + tinybox_bus::OBJECT_PATH, + tinybox_bus::INTERFACE, + )?; + + let capabilities: tinybox_bus::ModuleCapabilities = proxy.call("Capabilities", ()).await?; + assert_eq!(capabilities.contract_version, (1, 20)); + match mode { + "utf8" => { + let grants = resolve_grants(&proxy, &home, false).await?; + assert_selected_entries(&grants, &rustup_home, &cargo_home)?; + run_environment_shell(&proxy, &workspace, &state).await?; + } + #[cfg(target_os = "macos")] + "mac-bytes" => { + let grants = resolve_grants(&proxy, &home, true).await?; + assert_macos_byte_homes_omitted(&grants, &rustup_home, &cargo_home)?; + run_environment_shell(&proxy, &workspace, &state).await?; + } + "bytes" => { + let grants = resolve_grants(&proxy, &home, true).await?; + assert_selected_entries(&grants, &rustup_home, &cargo_home)?; + let legacy = proxy + .clone() + .with_timeout(GRANT_SCAN_TIMEOUT) + .call::( + "ResolveLocalGrants", + (grant_request(&home, false)?,), + ) + .await; + assert!( + matches!(legacy, Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::INVALID_FILE_PATH), + "legacy grant request did not refuse non-UTF-8 paths: {legacy:?}" + ); + run_environment_shell(&proxy, &workspace, &state).await?; + } + #[cfg(target_os = "linux")] + "local-tools" | "local-bytes" => { + let run_tools = mode == "local-tools"; + let grants = resolve_grants(&proxy, &home, true).await?; + assert_selected_entries(&grants, &rustup_home, &cargo_home)?; + run_environment_shell(&proxy, &workspace, &state).await?; + let toolchain = std::env::var("TINYBOX_SELECTED_TOOLCHAIN")?; + run_local_shell( + &proxy, &workspace, &state, &outside, grants, &toolchain, run_tools, + ) + .await?; + } + _ => return Err("unknown compiled-module fixture mode".into()), + } + + proxy.call::<()>("Shutdown", ()).await?; + broker_task.abort(); + Ok(()) +} + +fn env_path(name: &str) -> TestResult { + std::env::var_os(name) + .map(PathBuf::from) + .ok_or_else(|| format!("missing fixture environment value {name}").into()) +} + +fn grant_request( + home: &Path, + allow_unix_paths: bool, +) -> TestResult { + Ok(tinybox_bus::ResolveLocalGrantsRequest { + home: Some( + home.to_str() + .ok_or("fixture home path is not UTF-8")? + .into(), + ), + config: tinybox_bus::LocalGrantConfig { + toolchain_homes: true, + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths { + components: vec!["credentials.toml".into()], + ..tinybox_bus::ProtectedPaths::default() + }, + allow_unix_paths, + }) +} + +async fn resolve_grants( + proxy: &tinybus::Proxy, + home: &Path, + allow_unix_paths: bool, +) -> TestResult { + Ok(proxy + .clone() + .with_timeout(GRANT_SCAN_TIMEOUT) + .call( + "ResolveLocalGrants", + (grant_request(home, allow_unix_paths)?,), + ) + .await?) +} + +#[cfg(target_os = "macos")] +fn assert_macos_byte_homes_omitted( + grants: &tinybox_bus::LocalGrants, + rustup_home: &Path, + cargo_home: &Path, +) -> TestResult<()> { + use std::os::unix::ffi::OsStrExt; + + for path in [rustup_home, cargo_home] { + let Err(error) = fs::metadata(path) else { + return Err("resolver created a macOS invalid-byte home".into()); + }; + assert_eq!( + error.raw_os_error(), + Some(2), + "filesystem-rejected home should remain absent: {error}" + ); + let root = path.as_os_str().as_bytes(); + let utf8_grant = grants + .read_only + .iter() + .chain(&grants.read_write) + .any(|entry| grant_path_is_root_or_descendant(entry.as_bytes(), root)); + let unix_grant = grants + .unix_paths + .read_only + .iter() + .chain(&grants.unix_paths.read_write) + .any(|entry| grant_path_is_root_or_descendant(entry, root)); + assert!( + !utf8_grant && !unix_grant, + "filesystem-rejected missing home appeared in a grant group: {root:?}" + ); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn grant_path_is_root_or_descendant(candidate: &[u8], root: &[u8]) -> bool { + candidate == root + || candidate + .strip_prefix(root) + .is_some_and(|suffix| suffix.first() == Some(&b'/')) +} +fn assert_selected_entries( + grants: &tinybox_bus::LocalGrants, + rustup_home: &Path, + cargo_home: &Path, +) -> TestResult<()> { + assert_eq!(grants.read_write, Vec::::new()); + assert_eq!(grants.unix_paths.read_write, Vec::>::new()); + + assert!( + grant_contains(grants, rustup_home)?, + "custom Rustup home was not discovered: {}", + rustup_home.display() + ); + assert!( + !grant_contains_rw(grants, rustup_home)?, + "custom Rustup home was writable: {}", + rustup_home.display() + ); + + for entry in ["bin", "config.toml", "env", "registry", "git"] { + let path = cargo_home.join(entry); + assert!( + grant_contains(grants, &path)?, + "selected Cargo entry {entry} was not read-only" + ); + assert!( + !grant_contains_rw(grants, &path)?, + "selected Cargo entry {entry} was writable" + ); + } + + let credentials = cargo_home.join("credentials.toml"); + assert!( + !grant_contains(grants, cargo_home)?, + "the whole Cargo home was granted" + ); + assert!( + !grant_contains(grants, &credentials)?, + "Cargo credentials were granted" + ); + Ok(()) +} + +fn grant_contains(grants: &tinybox_bus::LocalGrants, path: &Path) -> TestResult { + let canonical = path.canonicalize()?; + if canonical.to_str().is_some() + && grants + .read_only + .iter() + .any(|entry| Path::new(entry) == canonical) + { + return Ok(true); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let bytes = canonical.as_os_str().as_bytes(); + Ok(grants + .unix_paths + .read_only + .iter() + .any(|entry| entry == bytes)) + } + #[cfg(not(unix))] + { + Ok(false) + } +} + +fn grant_contains_rw(grants: &tinybox_bus::LocalGrants, path: &Path) -> TestResult { + let canonical = path.canonicalize()?; + if canonical.to_str().is_some() + && grants + .read_write + .iter() + .any(|entry| Path::new(entry) == canonical) + { + return Ok(true); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let bytes = canonical.as_os_str().as_bytes(); + Ok(grants + .unix_paths + .read_write + .iter() + .any(|entry| entry == bytes)) + } + #[cfg(not(unix))] + { + Ok(false) + } +} + +async fn run_environment_shell( + proxy: &tinybus::Proxy, + workspace: &Path, + state: &Path, +) -> TestResult<()> { + #[cfg(unix)] + let command = r#"printf '%s\000%s' "$RUSTUP_HOME" "$CARGO_HOME""#; + #[cfg(windows)] + let command = "@chcp 65001 >NUL & @echo %RUSTUP_HOME%&@echo %CARGO_HOME%"; + let output = run_shell( + proxy, + workspace, + state, + command, + None, + std::collections::BTreeMap::new(), + ) + .await?; + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let rustup = std::env::var_os("RUSTUP_HOME").ok_or("child RUSTUP_HOME is missing")?; + let cargo = std::env::var_os("CARGO_HOME").ok_or("child CARGO_HOME is missing")?; + let expected = [ + rustup.as_os_str().as_bytes(), + b"\0", + cargo.as_os_str().as_bytes(), + ] + .concat(); + assert_eq!(output, expected, "native shell changed Rust home bytes"); + } + #[cfg(windows)] + { + let rustup = std::env::var_os("RUSTUP_HOME").ok_or("child RUSTUP_HOME is missing")?; + let cargo = std::env::var_os("CARGO_HOME").ok_or("child CARGO_HOME is missing")?; + let rustup = rustup + .to_str() + .ok_or("UTF-8 fixture RUSTUP_HOME changed encoding")?; + let cargo = cargo + .to_str() + .ok_or("UTF-8 fixture CARGO_HOME changed encoding")?; + let output = String::from_utf8(output)?; + assert_eq!(output.lines().collect::>(), [rustup, cargo]); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn assert_real_tool_versions(stdout: &str, run_tools: bool) { + if run_tools { + assert!( + stdout.contains("cargo "), + "cargo --version did not complete: {stdout}" + ); + assert!( + stdout.contains("rustc "), + "rustc --version did not complete: {stdout}" + ); + } +} +#[cfg(target_os = "linux")] +async fn run_local_shell( + proxy: &tinybus::Proxy, + workspace: &Path, + state: &Path, + outside: &Path, + grants: tinybox_bus::LocalGrants, + toolchain: &str, + run_tools: bool, +) -> TestResult<()> { + let status: tinybox_bus::JailStatus = proxy.call("JailStatus", ()).await?; + assert!( + status.available && status.filesystem == "enforced", + "Linux Local proof requires the default backend's enforced filesystem: {status:?}" + ); + let mut read_only = grants.read_only.clone(); + read_only.extend( + ["/usr", "/bin", "/lib", "/lib64", "/dev"] + .into_iter() + .filter(|path| Path::new(path).exists()) + .map(str::to_owned), + ); + let jail = tinybox_bus::NativeJailConfig { + read_only, + read_write: grants.read_write.clone(), + unix_paths: grants.unix_paths.clone(), + allow_net: true, + allow_subprocess: true, + enforcement: tinybox_bus::JailEnforcement::Enforced, + require_process_isolation: false, + }; + let extra_env = [ + ( + "OUTSIDE_SENTINEL".to_owned(), + outside + .to_str() + .ok_or("outside fixture is not UTF-8")? + .to_owned(), + ), + ("RUSTUP_TOOLCHAIN".to_owned(), toolchain.to_owned()), + ( + "TINYBOX_RUN_REAL_TOOLS".to_owned(), + if run_tools { "1" } else { "0" }.to_owned(), + ), + ] + .into_iter() + .collect(); + + let command = r#"set -u +cat "$RUSTUP_HOME/settings.toml" >/dev/null || exit 30 +cat "$CARGO_HOME/config.toml" >/dev/null || exit 31 +cat "$CARGO_HOME/env" >/dev/null || exit 32 +cat "$CARGO_HOME/registry/cache-fixture" >/dev/null || exit 33 +cat "$CARGO_HOME/git/cache-fixture" >/dev/null || exit 34 +if [ "$TINYBOX_RUN_REAL_TOOLS" = "1" ]; then + export PATH="$CARGO_HOME/bin:${PATH:-}" + cargo --version || exit 35 + rustc --version || exit 36 +fi +if printf denied > "$RUSTUP_HOME/.tinybox-write-probe" 2>/dev/null; then exit 41; fi +if printf denied > "$CARGO_HOME/config.toml" 2>/dev/null; then exit 42; fi +if printf denied > "$CARGO_HOME/registry/write-probe" 2>/dev/null; then exit 43; fi +if printf denied > "$CARGO_HOME/git/write-probe" 2>/dev/null; then exit 44; fi +if cat "$CARGO_HOME/credentials.toml" >/dev/null 2>&1; then exit 45; fi +if cat "$OUTSIDE_SENTINEL" >/dev/null 2>&1; then exit 46; fi +printf workspace-write > rust-homes-workspace-check +printf rust-homes-local-ok"#; + let output = run_shell(proxy, workspace, state, command, Some(jail), extra_env).await?; + let stdout = String::from_utf8(output)?; + assert_real_tool_versions(&stdout, run_tools); + assert!( + stdout.ends_with("rust-homes-local-ok"), + "Local toolchain checks did not reach their final boundary marker: {stdout}" + ); + assert_eq!( + fs::read(workspace.join("rust-homes-workspace-check"))?, + b"workspace-write" + ); + assert!( + !PathBuf::from(std::env::var_os("RUSTUP_HOME").ok_or("missing RUSTUP_HOME")?) + .join(".tinybox-write-probe") + .exists() + ); + assert!( + !PathBuf::from(std::env::var_os("CARGO_HOME").ok_or("missing CARGO_HOME")?) + .join("registry/write-probe") + .exists() + ); + assert!( + !PathBuf::from(std::env::var_os("CARGO_HOME").ok_or("missing CARGO_HOME")?) + .join("git/write-probe") + .exists() + ); + Ok(()) +} + +async fn run_shell( + proxy: &tinybus::Proxy, + workspace: &Path, + state: &Path, + command: &str, + jail: Option, + extra_env: std::collections::BTreeMap, +) -> TestResult> { + let workspace_text = workspace.to_str().ok_or("workspace path is not UTF-8")?; + let resource: tinybox_bus::ResourceId = proxy + .call("Reserve", (tinybox_bus::ReserveRequest::Resource,)) + .await?; + let _: tinybox_bus::ResourceInfo = proxy + .call( + "Create", + (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(workspace_text.into()), + ..Default::default() + },), + ) + .await?; + let process: tinybox_bus::ResourceId = proxy + .call( + "Reserve", + (tinybox_bus::ReserveRequest::Process(resource.clone()),), + ) + .await?; + let request = tinybox_bus::ShellExecRequest { + process, + resource: resource.clone(), + shell: tinybox_bus::SelectShellRequest { + command: command.into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: Some(workspace_text.into()), + extra_env, + state_dir: state.to_str().ok_or("state directory is not UTF-8")?.into(), + timeout_ms: 90_000, + stdin: None, + jail, + hide_window: false, + }; + let running: tinybox_bus::ProcessRef = proxy.call("StartNativeShellExec", (request,)).await?; + let batch = tokio::time::timeout(Duration::from_secs(100), async { + loop { + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (running.clone(), 0_u64)).await?; + if batch.state != tinybox_bus::ExecutionState::Running { + return Ok::<_, tinybus::Error>(batch); + } + tokio::task::yield_now().await; + } + }) + .await??; + let stderr: Vec<_> = batch + .chunks + .iter() + .filter(|chunk| chunk.stream == tinybox_bus::OutputStream::Stderr) + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect(); + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 }, + "native shell exited with stderr:\n{}", + String::from_utf8_lossy(&stderr) + ); + let output = batch + .chunks + .iter() + .filter(|chunk| chunk.stream == tinybox_bus::OutputStream::Stdout) + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect(); + proxy.call::<()>("ReleaseOutput", (running,)).await?; + proxy.call::<()>("Close", (resource,)).await?; + Ok(output) +} + +fn compiled_artifact() -> TestResult { + let filename = format!( + "{}tinybox{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/module-dynamic-proof"); + let status = Command::new(env!("CARGO")) + .args([ + "build", + "--locked", + "--offline", + "-p", + "tinybox-module", + "--no-default-features", + "--lib", + ]) + .arg("--manifest-path") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .status()?; + assert!( + status.success(), + "dynamic TinyBox module build failed before child env seeding: {status}" + ); + Ok(target.join("debug").join(filename)) +} +#[cfg(windows)] +fn secure_staging_directory(path: &Path) -> TestResult<()> { + let identity = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ]) + .output()?; + assert!( + identity.status.success(), + "current-user SID query failed: {identity:?}" + ); + let sid = String::from_utf8(identity.stdout)?; + let grant = format!("*{}:(OI)(CI)F", sid.trim()); + let acl = Command::new("icacls") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(grant) + .output()?; + assert!( + acl.status.success(), + "private module staging ACL failed: {acl:?}" + ); + Ok(()) +} diff --git a/docs/plans/local-grants.md b/docs/plans/local-grants.md index badca58b..30639358 100644 --- a/docs/plans/local-grants.md +++ b/docs/plans/local-grants.md @@ -25,3 +25,24 @@ and verification for the existing discovery slice. Discovery implementation is complete subject to review and recorded validation. Execution, released-module consumption and OpenHuman adapters are subsequent slices and are not represented as delivered by this prerequisite. + +## Custom Rust homes and Unix grant transport (contract 1.20) + +1. Reproduce custom UTF8 and Unix-byte Rust home discovery failures against + untouched production using subprocess-seeded public API tests. Reproduce + missing toolchain environment in an actual shell child the same way. +2. Discover configured absolute Rustup and Cargo homes as OS paths. Keep + Rustup and selected Cargo state read-only, preserve credential floors and + reserve aliases before admitting writable extras. +3. Snapshot nonempty RUSTUP_HOME and CARGO_HOME as OS values for native shell + execution, with approved UTF8 overrides retaining precedence. +4. Add opt-in byte grant transport and strict canonical decoding. Cover default + wire compatibility, malformed paths, combined bounds and approved flags. +5. Exercise the production-admitted dynamic module with UTF8 and Unix-byte + homes, actual Cargo/Rust commands and Linux filesystem confinement. + On macOS, assert the filesystem rejects invalid UTF8 names with EILSEQ and + discovery omits those unavailable homes; preserve byte-exact shell values. + On Unix filesystems accepting these names, assert actual byte-path grants. +6. Run all four canonical checks and the per-file coverage gate for the exact + candidate. Publish the reviewed upstream contribution from Sayr. +7. Integrate OpenHuman after the accepted TinyBox module release is available. diff --git a/docs/plans/native-shell-execution.md b/docs/plans/native-shell-execution.md index 0dbafc9a..aa28da49 100644 --- a/docs/plans/native-shell-execution.md +++ b/docs/plans/native-shell-execution.md @@ -18,3 +18,19 @@ Implementation is complete; upstream review and cross-platform CI remain. No host gitlink or availability claim is part of this upstream slice. + +## Rust toolchain environment + +1. Seed child tests through Command.env to reproduce lost RUSTUP_HOME and + CARGO_HOME bytes without changing the parent process environment. +2. Store environment values as OsString and include the two nonempty toolchain + names. Preserve approved overrides, ordinary passthrough validation, + Windows bootstrap, startup-hook removal and scratch ownership. +3. Verify the actual shell child sees exact OS bytes, empty homes are unset, + approved overrides win and unrelated ambient names stay excluded. +4. Verify the same behavior through a compiled production-admitted module and + Linux confinement using the grants from contract 1.20. + +5. On macOS, verify byte-exact shell values for invalid UTF8 home names whose + creation fails with EILSEQ, and require discovery to omit unavailable paths. + Retain actual byte-named directories and confinement checks on Linux. diff --git a/docs/specs/local-grants.md b/docs/specs/local-grants.md index 07cf7b31..3aa86eb7 100644 --- a/docs/specs/local-grants.md +++ b/docs/specs/local-grants.md @@ -29,7 +29,8 @@ supported escapes and unquoted comments preserve Git pathname semantics. ~user/ and %(prefix)/ expansions are unsupported: the complete request returns UnsupportedOperation instead of incomplete grants. -The bus rejects non-UTF-8 targets and more than 64 input or final grants. +The default bus request rejects non-UTF-8 targets. Contract 1.20 callers can +opt into raw Unix path grants. The combined grant limit remains 64. Missing/protected individual paths are omitted. Discovery is a snapshot; hosts must prevent later protected entries or concurrent tree mutation. No kernel deny rule for future credential filenames is claimed. @@ -38,3 +39,9 @@ Acceptance: behavioral regressions cover every floor and include correction; the actual dynamic artifact passes production manifest admission and discovery, including explicit refusal of unsupported include expansions. No implementation crate is needed by a host. Execution and host adapters remain separate work. + +## Custom Rust homes and Unix grant transport (contract 1.20) + +Existing absolute nonempty RUSTUP_HOME and CARGO_HOME paths are discovered on the module machine as OS paths. Defaults remain admitted by current toolchain policy. Rustup and selected Cargo bin/config/env/registry/git entries are read-only; credential floors and fixed-state alias protections apply to every selected root. Missing or invalid configured roots are skipped; discovery creates no directories. + +Callers opt into allow_unix_paths to receive non-UTF8 canonical Unix paths in an additive unix_paths grant group (raw byte vectors). UTF8 grants retain their established string representation. The same group can be supplied in native jail configuration after host approval. Empty optional groups preserve previous wire payloads. Module decoding rejects NUL, relative paths, parent traversal, oversized paths, unsupported platforms and combined grant counts above the established limit. Hosts require the new contract before sending byte grants and retain protected-path admission. diff --git a/docs/specs/native-shell-execution.md b/docs/specs/native-shell-execution.md index 1f45941c..7586a425 100644 --- a/docs/specs/native-shell-execution.md +++ b/docs/specs/native-shell-execution.md @@ -11,8 +11,9 @@ state_dir. This is local passthrough execution only; other providers and unavailable native jails refuse explicitly. The module owns shell selection and raw Windows quoting, snapshots only the -standard execution passthrough and Windows bootstrap names, rejects empty -present passthrough values, applies approved overrides, and finally removes +standard execution passthrough, Windows bootstrap names and nonempty Rust +toolchain homes. It rejects empty ordinary passthrough values, applies approved +overrides, and finally removes startup-hook variables. It clears the rest of the ambient environment. Each execution owns a private scratch directory under @@ -36,3 +37,7 @@ Acceptance proves actual compiled-module execution on Windows and Unix, quoting, environment isolation, scratch retention/cleanup, timeout/cancellation, bounded output, identity replay, admission refusal and unavailable confinement. OpenHuman host adaptation waits for an available released module. + +## Rust toolchain environment + +The native environment snapshots nonempty RUSTUP_HOME and CARGO_HOME as OS values, preserving Unix bytes. Approved UTF8 overrides retain precedence. Empty toolchain values behave as unset. Only these additional names are included; ordinary passthrough validation, Windows bootstrap, startup-hook removal and owned scratch remain unchanged. Canonical confinement grants are resolved separately using the current read-only host-state policy.