diff --git a/Cargo.lock b/Cargo.lock index 74719fb..ee74e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.3" @@ -120,10 +129,12 @@ dependencies = [ "clap", "dialoguer", "dirs", + "libc", "predicates", "regex", "serde", "serde_json", + "sha2", "similar", "tempfile", "thiserror 2.0.18", @@ -199,6 +210,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -218,6 +248,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -276,6 +316,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -654,6 +704,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -916,6 +977,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -940,6 +1007,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 3b98162..ffed825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } dialoguer = "0.11" dirs = "6" +libc = "0.2" regex = "1" serde = { version = "1", features = ["derive"] } +sha2 = "0.10" similar = { version = "2", features = ["text"] } thiserror = "2" tokio = { version = "1", features = ["full"] } diff --git a/cage.toml.example b/cage.toml.example new file mode 100644 index 0000000..584525c --- /dev/null +++ b/cage.toml.example @@ -0,0 +1,41 @@ +# cage.toml — project or global Cage configuration +# Global: ~/.config/cage/cage.toml +# Project: ./cage.toml (field-level overrides) +# +# Project configuration is treated as untrusted input. Cage displays the merged +# effective settings and requires approval on first use and after content changes. + +[environment] +# Omit to auto-detect. Supported explicit values: "docker" or "podman". +runtime = "docker" + +[defaults] +profile = "claude" +agent = "claude" + +[profiles.claude] +image = "node:20" +memory = "4g" +cpus = "2.0" +# Names only. Never place credential values in this file. +env = ["ANTHROPIC_API_KEY"] +gpus = [] +with_hooks = false +dind = false + +[profiles.codex] +image = "node:20" +memory = "4g" +cpus = "2.0" +env = ["OPENAI_API_KEY"] + +[sync] +include = ["src/**", "tests/**", "Cargo.toml"] +exclude = ["target/**", "node_modules/**", ".env"] + +# Custom adapters are resolved by Issue #10. +# [adapters.example] +# image = "node:20" +# command = ["example-agent"] +# env = ["EXAMPLE_API_KEY"] +# config_files = [".example/settings.toml"] diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..b1eddae --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,866 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Component, Path, PathBuf}, +}; + +use dialoguer::Confirm; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + CageConfig, CustomAdapterConfig, DefaultsConfig, EnvironmentConfig, ProfileConfig, SyncConfig, +}; + +/// Maximum accepted size for either configuration file. +pub const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +/// Explicit locations used by the configuration loader. +#[derive(Clone, Debug)] +pub struct ConfigPaths { + pub global: PathBuf, + pub project: PathBuf, + pub cage_home: PathBuf, +} + +impl ConfigPaths { + /// Resolve conventional global/project paths for a project directory. + #[must_use] + pub fn for_project(project_dir: &Path) -> Self { + let config_home = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config")); + let cage_home = + std::env::var_os("CAGE_HOME").map_or_else(|| config_home.join("cage"), PathBuf::from); + + Self { + global: config_home.join("cage").join("cage.toml"), + project: project_dir.join("cage.toml"), + cage_home, + } + } +} + +/// Typed configuration failures. Invalid or untrusted inputs always fail closed. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to inspect configuration {path}: {source}")] + Inspect { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("configuration {path} exceeds the {limit}-byte limit")] + TooLarge { path: PathBuf, limit: u64 }, + #[error("failed to read configuration {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("configuration {path} is not valid UTF-8")] + InvalidUtf8 { path: PathBuf }, + #[error("configuration {path} must be a regular file and cannot be a symlink")] + InvalidFileType { path: PathBuf }, + #[error("invalid TOML in {path}: {source}")] + InvalidToml { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + #[error("invalid configuration value at {field}: {reason}")] + InvalidValue { field: String, reason: String }, + #[error("profile '{0}' is not defined")] + UnknownProfile(String), + #[error( + "project configuration {path} is not trusted; review and approve it interactively before using non-interactive mode" + )] + UntrustedProject { path: PathBuf }, + #[error("project configuration {path} was rejected")] + ProjectRejected { path: PathBuf }, + #[error("failed to render effective project configuration: {0}")] + Render(#[from] toml::ser::Error), + #[error("failed to prompt for project configuration trust: {0}")] + Prompt(#[source] dialoguer::Error), + #[error("failed to update trust cache {path}: {source}")] + TrustCache { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Policy boundary for first-use project configuration approval. +pub trait ProjectConfigConfirmer { + /// Show the effective configuration and return whether it is approved. + /// + /// # Errors + /// + /// Returns a typed error if confirmation cannot be obtained. + fn confirm(&mut self, project_path: &Path, effective_toml: &str) -> Result; +} + +/// Fail-closed confirmer for automation and auto execution. +pub struct NonInteractiveConfirmer; + +impl ProjectConfigConfirmer for NonInteractiveConfirmer { + fn confirm(&mut self, project_path: &Path, _effective_toml: &str) -> Result { + Err(ConfigError::UntrustedProject { + path: project_path.to_owned(), + }) + } +} + +/// Interactive terminal confirmer. +pub struct DialoguerConfirmer; + +impl ProjectConfigConfirmer for DialoguerConfirmer { + fn confirm(&mut self, project_path: &Path, effective_toml: &str) -> Result { + Confirm::new() + .with_prompt(format!( + "Apply project configuration {}?\n\n{}", + project_path.display(), + effective_toml + )) + .default(false) + .interact() + .map_err(ConfigError::Prompt) + } +} + +/// Two-level configuration loader. +pub struct ConfigLoader { + paths: ConfigPaths, +} + +impl ConfigLoader { + #[must_use] + pub const fn new(paths: ConfigPaths) -> Self { + Self { paths } + } + + /// Load configuration for non-interactive execution. + /// + /// Previously acknowledged project content is accepted. New or changed project content fails + /// closed until an interactive load records approval. + /// + /// # Errors + /// + /// Returns a typed error for I/O, parsing, validation, or trust failures. + pub fn load_non_interactive(&self) -> Result { + self.load(&mut NonInteractiveConfirmer) + } + + /// Load, merge, validate, and (when necessary) confirm project configuration. + /// + /// # Errors + /// + /// Returns a typed error for I/O, parsing, validation, or trust failures. + pub fn load( + &self, + confirmer: &mut impl ProjectConfigConfirmer, + ) -> Result { + let global = read_optional(&self.paths.global)?.unwrap_or_default(); + let Some((project, project_bytes)) = read_optional_with_bytes(&self.paths.project)? else { + validate(&global)?; + return Ok(global); + }; + + let effective = merge(global, project); + validate(&effective)?; + + let canonical_project = + fs::canonicalize(&self.paths.project).map_err(|source| ConfigError::Inspect { + path: self.paths.project.clone(), + source, + })?; + let trust_key = trust_key(&canonical_project, &project_bytes); + let acknowledgement = self + .paths + .cage_home + .join("trusted-configs") + .join(format!("{trust_key}.ack")); + + if acknowledgement_is_valid(&acknowledgement, &canonical_project, &project_bytes)? { + return Ok(effective); + } + + let rendered = toml::to_string_pretty(&effective)?; + if !confirmer.confirm(&canonical_project, &rendered)? { + return Err(ConfigError::ProjectRejected { + path: canonical_project, + }); + } + + write_acknowledgement(&acknowledgement, &canonical_project, &project_bytes)?; + Ok(effective) + } +} + +fn read_optional(path: &Path) -> Result, ConfigError> { + read_optional_with_bytes(path).map(|value| value.map(|(config, _)| config)) +} + +fn read_optional_with_bytes(path: &Path) -> Result)>, ConfigError> { + let path_metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(ConfigError::Inspect { + path: path.to_owned(), + source, + }); + } + }; + + if !path_metadata.file_type().is_file() || path_metadata.file_type().is_symlink() { + return Err(ConfigError::InvalidFileType { + path: path.to_owned(), + }); + } + if path_metadata.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + path: path.to_owned(), + limit: MAX_CONFIG_BYTES, + }); + } + + let file = open_config(path).map_err(|source| ConfigError::Read { + path: path.to_owned(), + source, + })?; + let opened_metadata = file.metadata().map_err(|source| ConfigError::Inspect { + path: path.to_owned(), + source, + })?; + if !opened_metadata.file_type().is_file() { + return Err(ConfigError::InvalidFileType { + path: path.to_owned(), + }); + } + if opened_metadata.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + path: path.to_owned(), + limit: MAX_CONFIG_BYTES, + }); + } + let mut bytes = Vec::new(); + file.take(MAX_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| ConfigError::Read { + path: path.to_owned(), + source, + })?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + path: path.to_owned(), + limit: MAX_CONFIG_BYTES, + }); + } + + let content = + std::str::from_utf8(&bytes).map_err(|_| ConfigError::InvalidUtf8 { path: path.into() })?; + let config = toml::from_str(content).map_err(|source| ConfigError::InvalidToml { + path: path.to_owned(), + source, + })?; + Ok(Some((config, bytes))) +} + +fn open_config(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + options.open(path) +} + +fn merge(mut base: CageConfig, project: CageConfig) -> CageConfig { + merge_environment(&mut base.environment, project.environment); + merge_sync(&mut base.sync, project.sync); + merge_defaults(&mut base.defaults, project.defaults); + + for (name, project_profile) in project.profiles { + merge_profile(base.profiles.entry(name).or_default(), project_profile); + } + for (name, project_adapter) in project.adapters { + merge_adapter(base.adapters.entry(name).or_default(), project_adapter); + } + base +} + +fn merge_environment(base: &mut EnvironmentConfig, project: EnvironmentConfig) { + override_option(&mut base.runtime, project.runtime); +} + +fn merge_sync(base: &mut SyncConfig, project: SyncConfig) { + override_option(&mut base.include, project.include); + override_option(&mut base.exclude, project.exclude); +} + +fn merge_defaults(base: &mut DefaultsConfig, project: DefaultsConfig) { + override_option(&mut base.profile, project.profile); + override_option(&mut base.agent, project.agent); +} + +fn merge_profile(base: &mut ProfileConfig, project: ProfileConfig) { + override_option(&mut base.image, project.image); + override_option(&mut base.memory, project.memory); + override_option(&mut base.cpus, project.cpus); + override_option(&mut base.env, project.env); + override_option(&mut base.gpus, project.gpus); + override_option(&mut base.with_hooks, project.with_hooks); + override_option(&mut base.dind, project.dind); +} + +fn merge_adapter(base: &mut CustomAdapterConfig, project: CustomAdapterConfig) { + override_option(&mut base.image, project.image); + override_option(&mut base.command, project.command); + override_option(&mut base.env, project.env); + override_option(&mut base.config_files, project.config_files); +} + +fn override_option(base: &mut Option, project: Option) { + if project.is_some() { + *base = project; + } +} + +fn validate(config: &CageConfig) -> Result<(), ConfigError> { + if let Some(runtime) = &config.environment.runtime { + if runtime != "docker" && runtime != "podman" { + return invalid("environment.runtime", "expected 'docker' or 'podman'"); + } + } + + for (name, profile) in &config.profiles { + validate_name(&format!("profiles.{name}"), name)?; + if let Some(image) = &profile.image { + validate_image(&format!("profiles.{name}.image"), image)?; + } + if let Some(memory) = &profile.memory { + if !valid_memory(memory) { + return invalid( + &format!("profiles.{name}.memory"), + "expected a positive integer with an optional b, k, m, g, or t suffix", + ); + } + } + if let Some(cpus) = &profile.cpus { + let parsed = cpus.parse::().ok(); + if !parsed.is_some_and(|value| value.is_finite() && value > 0.0) { + return invalid( + &format!("profiles.{name}.cpus"), + "expected a finite number greater than zero", + ); + } + } + validate_env_names(&format!("profiles.{name}.env"), profile.env.as_deref())?; + if let Some(gpus) = &profile.gpus { + for gpu in gpus { + if gpu.is_empty() || !gpu.chars().all(safe_identifier_char) { + return invalid( + &format!("profiles.{name}.gpus"), + "GPU identifiers may contain only letters, digits, '.', '_', '-', ':' or '/'", + ); + } + } + } + if profile.dind == Some(true) { + return invalid( + &format!("profiles.{name}.dind"), + "DinD cannot be enabled until the safe sidecar policy is implemented", + ); + } + } + + for (name, adapter) in &config.adapters { + validate_name(&format!("adapters.{name}"), name)?; + let image = adapter + .image + .as_deref() + .ok_or_else(|| ConfigError::InvalidValue { + field: format!("adapters.{name}.image"), + reason: "custom adapters require an image".into(), + })?; + validate_image(&format!("adapters.{name}.image"), image)?; + if adapter.command.as_ref().is_none_or(Vec::is_empty) { + return invalid( + &format!("adapters.{name}.command"), + "custom adapters require a non-empty command", + ); + } + validate_env_names(&format!("adapters.{name}.env"), adapter.env.as_deref())?; + if let Some(paths) = &adapter.config_files { + validate_patterns(&format!("adapters.{name}.config_files"), paths)?; + } + } + + if let Some(profile) = &config.defaults.profile { + if !config.profiles.contains_key(profile) { + return Err(ConfigError::UnknownProfile(profile.clone())); + } + } + if let Some(patterns) = &config.sync.include { + validate_patterns("sync.include", patterns)?; + } + if let Some(patterns) = &config.sync.exclude { + validate_patterns("sync.exclude", patterns)?; + } + Ok(()) +} + +fn validate_name(field: &str, value: &str) -> Result<(), ConfigError> { + if value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + { + Ok(()) + } else { + invalid(field, "must contain only letters, digits, '_' or '-'") + } +} + +fn validate_image(field: &str, image: &str) -> Result<(), ConfigError> { + if !image.is_empty() && image.chars().all(safe_image_char) { + Ok(()) + } else { + invalid( + field, + "contains characters that are unsafe for an image reference", + ) + } +} + +fn validate_env_names(field: &str, values: Option<&[String]>) -> Result<(), ConfigError> { + for value in values.unwrap_or_default() { + if !value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_') + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + return invalid( + field, + &format!("'{value}' is not an environment variable name"), + ); + } + } + Ok(()) +} + +fn safe_image_char(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '/' | ':' | '@') +} + +fn safe_identifier_char(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '/' | ':') +} + +fn valid_memory(memory: &str) -> bool { + let digits = match memory.as_bytes().last() { + Some(b'b' | b'k' | b'm' | b'g' | b't') => &memory[..memory.len() - 1], + _ => memory, + }; + !digits.is_empty() + && digits.chars().all(|character| character.is_ascii_digit()) + && digits.parse::().is_ok_and(|value| value > 0) +} + +fn validate_patterns(field: &str, patterns: &[String]) -> Result<(), ConfigError> { + for pattern in patterns { + if pattern.is_empty() + || pattern.chars().any(char::is_control) + || Path::new(pattern).is_absolute() + || Path::new(pattern) + .components() + .any(|component| component == Component::ParentDir) + { + return invalid( + field, + &format!("'{pattern}' must be a safe relative path or glob"), + ); + } + } + Ok(()) +} + +fn invalid(field: &str, reason: &str) -> Result { + Err(ConfigError::InvalidValue { + field: field.to_owned(), + reason: reason.to_owned(), + }) +} + +fn trust_key(project_path: &Path, project_bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(project_path.as_os_str().as_encoded_bytes()); + digest.update([0]); + digest.update(project_bytes); + hex(&digest.finalize()) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +} + +fn acknowledgement_is_valid( + path: &Path, + project_path: &Path, + project_bytes: &[u8], +) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() && !metadata.file_type().is_symlink() => { + let expected = acknowledgement_content(project_path, project_bytes); + let actual = fs::read_to_string(path).map_err(|source| ConfigError::TrustCache { + path: path.to_owned(), + source, + })?; + if actual == expected { + Ok(true) + } else { + Err(ConfigError::TrustCache { + path: path.to_owned(), + source: std::io::Error::other("trust acknowledgement content is invalid"), + }) + } + } + Ok(_) => Err(ConfigError::TrustCache { + path: path.to_owned(), + source: std::io::Error::other("trust acknowledgement is not a regular file"), + }), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(ConfigError::TrustCache { + path: path.to_owned(), + source, + }), + } +} + +fn write_acknowledgement( + path: &Path, + project_path: &Path, + project_bytes: &[u8], +) -> Result<(), ConfigError> { + let parent = path.parent().ok_or_else(|| ConfigError::TrustCache { + path: path.to_owned(), + source: std::io::Error::other("trust cache path has no parent"), + })?; + fs::create_dir_all(parent).map_err(|source| ConfigError::TrustCache { + path: parent.to_owned(), + source, + })?; + + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { + if acknowledgement_is_valid(path, project_path, project_bytes)? { + return Ok(()); + } + return Err(ConfigError::TrustCache { + path: path.to_owned(), + source: std::io::Error::other( + "concurrent trust acknowledgement did not match the approved configuration", + ), + }); + } + Err(source) => { + return Err(ConfigError::TrustCache { + path: path.to_owned(), + source, + }); + } + }; + file.write_all(acknowledgement_content(project_path, project_bytes).as_bytes()) + .map_err(|source| ConfigError::TrustCache { + path: path.to_owned(), + source, + }) +} + +fn acknowledgement_content(project_path: &Path, project_bytes: &[u8]) -> String { + format!( + "project={}\ncontent_sha256={}\n", + project_path.display(), + hex(&Sha256::digest(project_bytes)) + ) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct Accept; + + impl ProjectConfigConfirmer for Accept { + fn confirm(&mut self, _path: &Path, effective: &str) -> Result { + assert!(effective.contains("[environment]")); + Ok(true) + } + } + + struct Reject; + + impl ProjectConfigConfirmer for Reject { + fn confirm(&mut self, _path: &Path, _effective: &str) -> Result { + Ok(false) + } + } + + fn paths(root: &TempDir) -> ConfigPaths { + ConfigPaths { + global: root.path().join("global.toml"), + project: root.path().join("project").join("cage.toml"), + cage_home: root.path().join("home"), + } + } + + fn write(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + #[test] + fn defaults_when_files_are_absent() { + let root = TempDir::new().unwrap(); + let loaded = ConfigLoader::new(paths(&root)) + .load_non_interactive() + .unwrap(); + assert_eq!(loaded, CageConfig::default()); + } + + #[test] + fn loads_global_only_without_project_confirmation() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write( + &paths.global, + "[environment]\nruntime = \"docker\"\n[profiles.base]\nmemory = \"4g\"\n", + ); + let loaded = ConfigLoader::new(paths).load_non_interactive().unwrap(); + assert_eq!(loaded.runtime(), Some("docker")); + assert_eq!( + loaded.profile("base").unwrap().memory.as_deref(), + Some("4g") + ); + } + + #[test] + fn resolved_profile_applies_safe_resource_defaults() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.global, "[profiles.base]\nimage = \"node:20\"\n"); + let loaded = ConfigLoader::new(paths).load_non_interactive().unwrap(); + let profile = loaded.resolved_profile("base").unwrap(); + assert_eq!(profile.memory.as_deref(), Some(CageConfig::DEFAULT_MEMORY)); + assert_eq!(profile.cpus.as_deref(), Some(CageConfig::DEFAULT_CPUS)); + } + + #[test] + fn project_overrides_global_fields_and_preserves_other_fields() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write( + &paths.global, + "[environment]\nruntime = \"docker\"\n[profiles.dev]\nmemory = \"4g\"\ncpus = \"2.0\"\nenv = [\"GLOBAL_TOKEN\"]\n", + ); + write( + &paths.project, + "[environment]\nruntime = \"podman\"\n[profiles.dev]\nmemory = \"8g\"\nimage = \"node:20\"\n", + ); + let loaded = ConfigLoader::new(paths).load(&mut Accept).unwrap(); + let profile = loaded.profile("dev").unwrap(); + assert_eq!(loaded.runtime(), Some("podman")); + assert_eq!(profile.memory.as_deref(), Some("8g")); + assert_eq!(profile.cpus.as_deref(), Some("2.0")); + assert_eq!(profile.image.as_deref(), Some("node:20")); + assert_eq!( + profile.env.as_deref(), + Some(&[String::from("GLOBAL_TOKEN")][..]) + ); + } + + #[test] + fn unchanged_project_is_accepted_noninteractively_after_approval() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.project, "[profiles.dev]\nmemory = \"8g\"\n"); + ConfigLoader::new(paths.clone()).load(&mut Accept).unwrap(); + ConfigLoader::new(paths).load_non_interactive().unwrap(); + } + + #[test] + fn changed_project_requires_fresh_approval() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.project, "[profiles.dev]\nmemory = \"8g\"\n"); + ConfigLoader::new(paths.clone()).load(&mut Accept).unwrap(); + write(&paths.project, "[profiles.dev]\nmemory = \"16g\"\n"); + let error = ConfigLoader::new(paths).load_non_interactive().unwrap_err(); + assert!(matches!(error, ConfigError::UntrustedProject { .. })); + } + + #[test] + fn rejected_project_is_not_cached() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.project, "[profiles.dev]\nmemory = \"8g\"\n"); + let error = ConfigLoader::new(paths.clone()) + .load(&mut Reject) + .unwrap_err(); + assert!(matches!(error, ConfigError::ProjectRejected { .. })); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::UntrustedProject { .. }) + )); + } + + #[test] + fn invalid_toml_returns_an_error() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.global, "[[not valid"); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidToml { .. }) + )); + } + + #[test] + fn oversized_configuration_is_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + let oversized = usize::try_from(MAX_CONFIG_BYTES + 1).unwrap(); + fs::write(&paths.global, vec![b'x'; oversized]).unwrap(); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::TooLarge { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn configuration_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + let paths = paths(&root); + let target = root.path().join("target.toml"); + write(&target, "[environment]\nruntime = \"docker\"\n"); + symlink(target, &paths.global).unwrap(); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidFileType { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn configuration_fifo_is_rejected_without_blocking() { + use std::process::Command; + + let root = TempDir::new().unwrap(); + let paths = paths(&root); + let status = Command::new("mkfifo").arg(&paths.global).status().unwrap(); + assert!(status.success()); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidFileType { .. }) + )); + } + + #[test] + fn malicious_image_and_environment_value_are_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write( + &paths.global, + "[profiles.bad]\nimage = \"ubuntu; curl attacker\"\nenv = [\"TOKEN=value\"]\n", + ); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidValue { .. }) + )); + } + + #[test] + fn hardening_adjacent_and_unknown_keys_are_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.global, "[profiles.bad]\nprivileged = true\n"); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidToml { .. }) + )); + } + + #[test] + fn invalid_resources_and_dind_enablement_are_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write( + &paths.global, + "[profiles.bad]\nmemory = \"4g;rm\"\ncpus = \"NaN\"\ndind = true\n", + ); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidValue { .. }) + )); + } + + #[test] + fn invalid_default_profile_is_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.global, "[defaults]\nprofile = \"missing\"\n"); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::UnknownProfile(name)) if name == "missing" + )); + } + + #[test] + fn unsafe_sync_paths_are_rejected() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.global, "[sync]\ninclude = [\"../secrets/**\"]\n"); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::InvalidValue { .. }) + )); + } + + #[test] + fn corrupted_acknowledgement_fails_closed() { + let root = TempDir::new().unwrap(); + let paths = paths(&root); + write(&paths.project, "[profiles.dev]\nmemory = \"8g\"\n"); + ConfigLoader::new(paths.clone()).load(&mut Accept).unwrap(); + let acknowledgement = fs::read_dir(paths.cage_home.join("trusted-configs")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + fs::write(acknowledgement, "corrupted").unwrap(); + assert!(matches!( + ConfigLoader::new(paths).load_non_interactive(), + Err(ConfigError::TrustCache { .. }) + )); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index d041969..15ed8e7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1 +1,109 @@ //! Cage configuration loading and validation. + +mod loader; + +use std::collections::BTreeMap; + +pub use loader::{ + ConfigError, ConfigLoader, ConfigPaths, DialoguerConfirmer, MAX_CONFIG_BYTES, + NonInteractiveConfirmer, ProjectConfigConfirmer, +}; +use serde::{Deserialize, Serialize}; + +/// Fully merged Cage configuration. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct CageConfig { + pub environment: EnvironmentConfig, + pub profiles: BTreeMap, + pub sync: SyncConfig, + pub adapters: BTreeMap, + pub defaults: DefaultsConfig, +} + +/// Container environment selection. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct EnvironmentConfig { + /// Container runtime preference (docker or podman). + pub runtime: Option, +} + +/// Named agent profile. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ProfileConfig { + pub image: Option, + pub memory: Option, + pub cpus: Option, + /// Environment variable names. Values are never stored in configuration. + pub env: Option>, + pub gpus: Option>, + pub with_hooks: Option, + pub dind: Option, +} + +/// Workspace synchronization filters. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct SyncConfig { + pub include: Option>, + pub exclude: Option>, +} + +/// User-defined agent adapter. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct CustomAdapterConfig { + pub image: Option, + pub command: Option>, + /// Environment variable names. Values are resolved later by the credential layer. + pub env: Option>, + pub config_files: Option>, +} + +/// Default selections used when the CLI does not provide an override. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct DefaultsConfig { + pub profile: Option, + pub agent: Option, +} + +impl CageConfig { + pub const DEFAULT_MEMORY: &'static str = "4g"; + pub const DEFAULT_CPUS: &'static str = "2.0"; + + /// Resolve a required profile by name. + /// + /// # Errors + /// + /// Returns `ConfigError::UnknownProfile` when the profile does not exist. + pub fn profile(&self, name: &str) -> Result<&ProfileConfig, ConfigError> { + self.profiles + .get(name) + .ok_or_else(|| ConfigError::UnknownProfile(name.to_owned())) + } + + /// Resolve a profile and apply the resource defaults owned by the configuration layer. + /// + /// # Errors + /// + /// Returns `ConfigError::UnknownProfile` when the profile does not exist. + pub fn resolved_profile(&self, name: &str) -> Result { + let mut profile = self.profile(name)?.clone(); + profile + .memory + .get_or_insert_with(|| Self::DEFAULT_MEMORY.to_owned()); + profile + .cpus + .get_or_insert_with(|| Self::DEFAULT_CPUS.to_owned()); + Ok(profile) + } + + /// Runtime preference for the Docker/Podman selector. + #[must_use] + pub fn runtime(&self) -> Option<&str> { + self.environment.runtime.as_deref() + } +}