//! The operator config surface (RFC-007): `~/.omnigraph/config.yaml` — who //! the operator IS (identity, ergonomics), never what the system is (that's //! cluster config) and never a project file (nothing here arrives with a //! repo checkout). //! //! PR-1 scope: `operator.actor` + `defaults.output`. Unknown keys WARN and //! are preserved-by-ignoring — a file written for a newer CLI (servers, //! aliases, credentials keys from later slices) must load cleanly on this //! one. Contrast with `cluster.yaml`, where unknown keys are fatal because //! they change what a plan means. //! //! This module is CLI-only by design: the server never reads operator //! config (server-side identity comes from bearer auth — invariant 11 //! holds by construction). use std::collections::BTreeMap; use std::env; use std::path::{Path, PathBuf}; use color_eyre::Result; use color_eyre::eyre::{bail, eyre}; use serde::Deserialize; use crate::read_format::{ReadOutputFormat, TableCellLayout}; pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME"; pub(crate) const OPERATOR_DIR: &str = ".omnigraph"; pub(crate) const OPERATOR_CONFIG_FILE: &str = "config.yaml"; #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorConfig { #[serde(default)] pub(crate) operator: OperatorIdentity, #[serde(default)] pub(crate) defaults: OperatorDefaults, /// Operator-owned endpoint definitions (RFC-007 §D2/§D4): name → url. /// The name keys the credential chain; nothing a repo checkout supplies /// can redefine an entry here. No tokens in this file, ever. #[serde(default)] pub(crate) servers: BTreeMap, /// Personal alias bindings (RFC-007 PR 3); see OperatorAlias. #[serde(default)] pub(crate) aliases: BTreeMap, /// Named scope bundles (RFC-011): each binds exactly one of /// {server, cluster, store} plus an optional default graph. Config data, /// not state — selecting one (`--profile`/`OMNIGRAPH_PROFILE`) fills in a /// command's omitted addressing; it never puts you "in" a mode. #[serde(default)] pub(crate) profiles: BTreeMap, /// Managed-cluster storage roots (RFC-011): name → root URI. The ONLY /// place a storage root appears in operator config — admin-only and /// opt-in; a normal operator's file has none. #[serde(default)] pub(crate) clusters: BTreeMap, /// Everything this CLI version doesn't know. Warned once at load, /// otherwise ignored (forward compatibility within the operator layer). #[serde(flatten)] unknown: serde_yaml::Mapping, } /// A personal alias: a pure BINDING to a stored query on a named server — /// never content, never a file (RFC-007 §D2 "Aliases are bindings, not /// content"). The stored query is the team's contract; the alias, its /// defaults, and its name are the operator's. #[derive(Debug, Deserialize)] pub(crate) struct OperatorAlias { /// Names an entry under `servers:`. pub(crate) server: String, /// Graph id for multi-graph servers (appends `/graphs/`). pub(crate) graph: Option, /// The STORED query's name on that server. pub(crate) query: String, /// Positional CLI args bind to these param names, in order. #[serde(default)] pub(crate) args: Vec, /// Fixed default params; positionals and `--params` override per key. #[serde(default)] pub(crate) params: serde_yaml::Mapping, pub(crate) format: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, } #[derive(Debug, Deserialize)] pub(crate) struct OperatorServer { pub(crate) url: String, #[serde(flatten)] unknown: serde_yaml::Mapping, } #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorIdentity { /// Default actor for every `--as` cascade (CLI direct-engine writes and /// cluster commands alike): `--as` > this > none. pub(crate) actor: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, } #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorDefaults { /// Default read output format, below every more-specific source. pub(crate) output: Option, /// Table rendering preferences for `--format table`. pub(crate) table_max_column_width: Option, pub(crate) table_cell_layout: Option, /// Default server scope (RFC-011): the everyday addressing when no /// `--profile` / primitive / legacy address is given. Names an entry /// under `servers:`. Mutually exclusive with `store` — a scope binds one /// entity. pub(crate) server: Option, /// Default **store** scope (RFC-011): a `file://` / `s3://` graph storage /// URI used as the zero-flag local default for graph commands when no /// `--profile` / primitive address is given. The local-dev counterpart of /// `server`; mutually exclusive with it. pub(crate) store: Option, /// Default graph selected within a server/cluster scope when no /// `--graph` is passed (RFC-011). pub(crate) default_graph: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, } /// A named scope bundle (RFC-011): exactly one of {server, cluster, store} /// plus an optional default graph. Validated on use (`binding()`), not at /// parse time, so an unknown CLI's profile still loads. #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorProfile { /// Names an entry under `servers:` — a served scope. pub(crate) server: Option, /// Names an entry under `clusters:` — a privileged direct cluster scope. pub(crate) cluster: Option, /// A single graph's storage URI — a direct store scope. pub(crate) store: Option, /// Default graph within a server/cluster scope (ignored for a store, /// which is already one graph). pub(crate) default_graph: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, } /// A managed-cluster storage root (RFC-011). #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorCluster { /// The cluster's storage-root URI (`file://` / `s3://`). pub(crate) root: String, #[serde(flatten)] unknown: serde_yaml::Mapping, } /// The one entity a profile (or flat default) binds. Exactly one variant — /// the scope resolver consumes this; "exactly one of server/cluster/store" /// is enforced when producing it. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ScopeBinding { /// Served scope: a server name (resolved against `servers:`) or a literal URL. Server(String), /// Direct cluster scope: a cluster name (resolved against `clusters:`) or a /// literal root URI. Cluster(String), /// Direct store scope: a single graph's storage URI. Store(String), } impl OperatorConfig { pub(crate) fn actor(&self) -> Option<&str> { self.operator.actor.as_deref() } pub(crate) fn output(&self) -> Option { self.defaults.output } /// The gh-host model: which operator server (if any) does this request /// URL belong to? Longest-prefix match after trailing-slash /// normalization, so `url: http://h:8080` matches /// `http://h:8080/graphs/spike` but never `http://h:8080-evil`. pub(crate) fn find_server_for_url(&self, request_url: &str) -> Option<&str> { let request = request_url.trim_end_matches('/'); let mut best: Option<(&str, usize)> = None; for (name, server) in &self.servers { let base = server.url.trim_end_matches('/'); let matches = request == base || request .strip_prefix(base) .is_some_and(|rest| rest.starts_with('/')); if matches && best.is_none_or(|(_, len)| base.len() > len) { best = Some((name, base.len())); } } best.map(|(name, _)| name) } /// A named profile, if defined (RFC-011). pub(crate) fn profile(&self, name: &str) -> Option<&OperatorProfile> { self.profiles.get(name) } /// The storage root of a named cluster, if defined (RFC-011). pub(crate) fn cluster_root(&self, name: &str) -> Option<&str> { self.clusters.get(name).map(|c| c.root.as_str()) } /// The flat-default server scope name, if set (RFC-011). pub(crate) fn default_server(&self) -> Option<&str> { self.defaults.server.as_deref() } /// The flat-default store scope URI, if set (RFC-011) — the zero-flag /// local-dev default. pub(crate) fn default_store(&self) -> Option<&str> { self.defaults.store.as_deref() } /// The flat-default graph within a server/cluster scope, if set (RFC-011). pub(crate) fn default_graph(&self) -> Option<&str> { self.defaults.default_graph.as_deref() } /// A scope binds one entity (Decision 6): `defaults.server` and /// `defaults.store` are mutually exclusive, and a `store` (already a single /// graph) cannot carry a `default_graph`. Both are refused loudly rather /// than silently dropped. fn validate_defaults(&self) -> Result<()> { if self.defaults.server.is_some() && self.defaults.store.is_some() { bail!( "operator config `defaults` sets both `server` and `store` — a default scope \ binds one entity; keep one (use a `profile` if you need both)" ); } if self.defaults.store.is_some() && self.defaults.default_graph.is_some() { bail!( "operator config `defaults` sets both `store` and `default_graph` — a store is \ already a single graph; drop `default_graph` (it applies only to a server/cluster scope)" ); } Ok(()) } } impl OperatorProfile { /// The single entity this profile binds, or a loud error if it binds zero /// or more than one of {server, cluster, store} (Decision 6: a scope binds /// exactly one entity). Validated here, on use, rather than at parse time. pub(crate) fn binding(&self, profile_name: &str) -> Result { let set: Vec<&str> = [ self.server.as_ref().map(|_| "server"), self.cluster.as_ref().map(|_| "cluster"), self.store.as_ref().map(|_| "store"), ] .into_iter() .flatten() .collect(); match set.as_slice() { ["server"] => Ok(ScopeBinding::Server(self.server.clone().unwrap())), ["cluster"] => Ok(ScopeBinding::Cluster(self.cluster.clone().unwrap())), ["store"] => Ok(ScopeBinding::Store(self.store.clone().unwrap())), [] => Err(eyre!( "profile '{profile_name}' binds no scope; set exactly one of \ `server`, `cluster`, or `store`" )), many => Err(eyre!( "profile '{profile_name}' binds {} scopes ({}); a profile must \ bind exactly one of `server`, `cluster`, or `store`", many.len(), many.join(", ") )), } } } /// The operator dir: `$OMNIGRAPH_HOME` if set (tilde-expanded), else /// `~/.omnigraph`. Returns None when no home directory is resolvable /// (degenerate environments — the layer is simply absent). pub(crate) fn operator_dir() -> Option { if let Some(home_override) = env::var_os(OPERATOR_HOME_ENV) { let raw = home_override.to_string_lossy().into_owned(); return Some(expand_tilde(&raw)); } env::home_dir().map(|home| home.join(OPERATOR_DIR)) } /// Load the operator layer. Absent file (or unresolvable home) is an empty /// layer, never an error; a present-but-malformed file is a loud error (the /// operator owns it and can fix it); unknown keys warn to stderr once. pub(crate) fn load_operator_config() -> Result { let Some(dir) = operator_dir() else { return Ok(OperatorConfig::default()); }; load_operator_config_at(&dir.join(OPERATOR_CONFIG_FILE)) } pub(crate) fn load_operator_config_at(path: &Path) -> Result { let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(OperatorConfig::default()); } Err(err) => { return Err(eyre!( "could not read operator config '{}': {err}", path.display() )); } }; let config: OperatorConfig = serde_yaml::from_str(&text).map_err(|err| { eyre!( "could not parse operator config '{}': {err}", path.display() ) })?; for warning in config.unknown_key_warnings() { eprintln!("warning: {warning} in operator config '{}'", path.display()); } config.validate_defaults()?; Ok(config) } impl OperatorConfig { fn unknown_key_warnings(&self) -> Vec { let mut warnings = Vec::new(); let mut collect = |mapping: &serde_yaml::Mapping, prefix: &str| { for key in mapping.keys() { if let Some(name) = key.as_str() { warnings.push(format!( "unknown key `{prefix}{name}` (newer CLI feature or typo); ignored" )); } } }; collect(&self.unknown, ""); collect(&self.operator.unknown, "operator."); collect(&self.defaults.unknown, "defaults."); for (name, server) in &self.servers { collect(&server.unknown, &format!("servers.{name}.")); } for (name, alias) in &self.aliases { collect(&alias.unknown, &format!("aliases.{name}.")); } for (name, profile) in &self.profiles { collect(&profile.unknown, &format!("profiles.{name}.")); } for (name, cluster) in &self.clusters { collect(&cluster.unknown, &format!("clusters.{name}.")); } warnings } } // ---- keyed credentials (RFC-007 §D4) ---- pub(crate) const CREDENTIALS_FILE: &str = "credentials"; const TOKEN_ENV_PREFIX: &str = "OMNIGRAPH_TOKEN_"; pub(crate) fn credentials_path() -> Option { operator_dir().map(|dir| dir.join(CREDENTIALS_FILE)) } /// `intel-dev` → `OMNIGRAPH_TOKEN_INTEL_DEV`. pub(crate) fn token_env_name(server: &str) -> String { let mut name = String::from(TOKEN_ENV_PREFIX); for c in server.chars() { name.push(match c { '-' => '_', other => other.to_ascii_uppercase(), }); } name } /// The keyed token chain for a named server (§D4 steps 1–2): /// `OMNIGRAPH_TOKEN_` env → `[]` in the credentials file. /// `Ok(None)` means "no keyed token" — callers fall through to the legacy /// chain; a present-but-unreadable/over-permissive credentials file is a /// loud error, never a silent skip. pub(crate) fn resolve_keyed_token(server: &str) -> Result> { if let Ok(token) = env::var(token_env_name(server)) { let token = token.trim(); if !token.is_empty() { return Ok(Some(token.to_string())); } } let Some(path) = credentials_path() else { return Ok(None); }; read_credential_at(&path, server) } pub(crate) fn read_credential_at(path: &Path, server: &str) -> Result> { let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) => { return Err(eyre!( "could not read credentials file '{}': {err}", path.display() )); } }; refuse_over_permissive(path)?; let mut in_section = false; for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some(section) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { in_section = section.trim() == server; continue; } if in_section { if let Some((key, value)) = line.split_once('=') { if key.trim() == "token" { let value = unquote(value.trim()); if value.is_empty() { return Ok(None); } return Ok(Some(value.to_string())); } } } } Ok(None) } /// Write (or rotate) one server's token, preserving every other section. /// Temp file + rename (#139 finding 7), created 0600. pub(crate) fn write_credential(server: &str, token: &str) -> Result { let path = credentials_path() .ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?; rewrite_credentials_at(&path, server, Some(token))?; Ok(path) } /// Remove one server's section. Idempotent: absent file or section is fine. pub(crate) fn remove_credential(server: &str) -> Result { let path = credentials_path() .ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?; rewrite_credentials_at(&path, server, None)?; Ok(path) } pub(crate) fn rewrite_credentials_at( path: &Path, server: &str, token: Option<&str>, ) -> Result<()> { let existing = match std::fs::read_to_string(path) { Ok(text) => { refuse_over_permissive(path)?; text } Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), Err(err) => { return Err(eyre!( "could not read credentials file '{}': {err}", path.display() )); } }; // Drop the target section (if present), keep everything else verbatim. let mut out = String::new(); let mut in_target = false; for line in existing.lines() { let trimmed = line.trim(); if let Some(section) = trimmed.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { in_target = section.trim() == server; if in_target { continue; } } if !in_target { out.push_str(line); out.push('\n'); } } if let Some(token) = token { if !out.is_empty() && !out.ends_with("\n\n") { out.push('\n'); } out.push_str(&format!("[{server}]\ntoken = {token}\n")); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let tmp = path.with_extension(format!("tmp.{}", std::process::id())); write_owner_only(&tmp, &out)?; std::fs::rename(&tmp, path).map_err(|err| { let _ = std::fs::remove_file(&tmp); eyre!( "could not move credentials file into place '{}': {err}", path.display() ) })?; Ok(()) } #[cfg(unix)] fn write_owner_only(path: &Path, content: &str) -> Result<()> { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path)?; file.write_all(content.as_bytes())?; Ok(()) } #[cfg(not(unix))] fn write_owner_only(path: &Path, content: &str) -> Result<()> { std::fs::write(path, content)?; Ok(()) } /// Secrets are operator-private: refuse a credentials file other accounts /// can read (the chain errs loudly rather than using a leaked secret). #[cfg(unix)] fn refuse_over_permissive(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(path)?.permissions().mode(); if mode & 0o077 != 0 { return Err(eyre!( "credentials file '{}' is group/world-accessible (mode {:o}); run `chmod 600 {}`", path.display(), mode & 0o777, path.display() )); } Ok(()) } #[cfg(not(unix))] fn refuse_over_permissive(_path: &Path) -> Result<()> { Ok(()) } fn unquote(value: &str) -> &str { if value.len() >= 2 && ((value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\''))) { &value[1..value.len() - 1] } else { value } } /// Expand a leading `~` / `~/` to the home directory (PR #139 finding 9: /// a literal `./~/…` path silently created a directory named `~`). pub(crate) fn expand_tilde(raw: &str) -> PathBuf { if raw == "~" { return env::home_dir().unwrap_or_else(|| PathBuf::from(raw)); } if let Some(rest) = raw.strip_prefix("~/") { if let Some(home) = env::home_dir() { return home.join(rest); } } PathBuf::from(raw) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn absent_file_is_an_empty_layer() { let dir = tempfile::tempdir().unwrap(); let config = load_operator_config_at(&dir.path().join("config.yaml")).unwrap(); assert!(config.actor().is_none()); assert!(config.output().is_none()); } #[test] fn parses_identity_and_defaults() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "operator:\n actor: act-andrew\ndefaults:\n output: json\n", ) .unwrap(); let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.actor(), Some("act-andrew")); assert_eq!(config.output(), Some(ReadOutputFormat::Json)); } #[test] fn defaults_store_parses_and_is_accessible() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write(&path, "defaults:\n store: file:///tmp/dev.omni\n").unwrap(); let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.default_store(), Some("file:///tmp/dev.omni")); assert_eq!(config.default_server(), None); } #[test] fn defaults_server_and_store_together_is_a_loud_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "defaults:\n server: prod\n store: file:///tmp/dev.omni\n", ) .unwrap(); let err = load_operator_config_at(&path).unwrap_err().to_string(); assert!(err.contains("binds one entity"), "{err}"); } #[test] fn defaults_store_with_default_graph_is_a_loud_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "defaults:\n store: file:///tmp/dev.omni\n default_graph: knowledge\n", ) .unwrap(); let err = load_operator_config_at(&path).unwrap_err().to_string(); assert!(err.contains("already a single graph"), "{err}"); } #[test] fn unknown_keys_warn_but_load() { // A file written for a later slice (servers/aliases) must load // cleanly today — warn-only forward compatibility. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "operator:\n actor: act-a\n color: green\nservers:\n prod:\n url: https://example.com\naliases: {}\n", ) .unwrap(); let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.actor(), Some("act-a")); let warnings = config.unknown_key_warnings(); // `servers` (PR 2) and `aliases` (PR 3) are known keys now. assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("`operator.color`"))); assert_eq!(config.servers["prod"].url, "https://example.com"); } #[test] fn parses_profiles_clusters_and_scope_defaults() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); let yaml = "\ defaults: server: prod default_graph: knowledge servers: prod: url: https://example.com clusters: brain: root: s3://acme/clusters/brain profiles: staging: server: staging default_graph: knowledge brain-admin: cluster: brain default_graph: knowledge "; fs::write(&path, yaml).unwrap(); let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.default_server(), Some("prod")); assert_eq!(config.default_graph(), Some("knowledge")); assert_eq!(config.cluster_root("brain"), Some("s3://acme/clusters/brain")); assert_eq!( config.profile("staging").unwrap().binding("staging").unwrap(), ScopeBinding::Server("staging".into()) ); assert_eq!( config .profile("brain-admin") .unwrap() .binding("brain-admin") .unwrap(), ScopeBinding::Cluster("brain".into()) ); // No unknown-key warnings for the new blocks. assert!(config.unknown_key_warnings().is_empty(), "{:?}", config.unknown_key_warnings()); } #[test] fn profile_binding_rejects_zero_or_multiple_entities() { let none = OperatorProfile::default(); let err = none.binding("p").unwrap_err().to_string(); assert!(err.contains("binds no scope"), "{err}"); let two = OperatorProfile { server: Some("prod".into()), store: Some("graph.omni".into()), ..Default::default() }; let err = two.binding("p").unwrap_err().to_string(); assert!(err.contains("binds 2 scopes"), "{err}"); assert!(err.contains("server") && err.contains("store"), "{err}"); } #[test] fn unknown_keys_in_a_profile_warn() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "profiles:\n p:\n server: prod\n flavour: spicy\n", ) .unwrap(); let config = load_operator_config_at(&path).unwrap(); let warnings = config.unknown_key_warnings(); assert!( warnings.iter().any(|w| w.contains("`profiles.p.flavour`")), "{warnings:?}" ); } #[test] fn malformed_yaml_is_a_loud_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write(&path, "operator: [not, a, mapping\n").unwrap(); let err = load_operator_config_at(&path).unwrap_err(); assert!(err.to_string().contains("could not parse operator config")); } #[test] fn find_server_for_url_longest_prefix_no_substring_traps() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "servers:\n dev:\n url: http://h:8080\n dev-spike:\n url: http://h:8080/graphs/spike\n", ) .unwrap(); let config = load_operator_config_at(&path).unwrap(); assert_eq!(config.find_server_for_url("http://h:8080"), Some("dev")); assert_eq!( config.find_server_for_url("http://h:8080/graphs/other"), Some("dev") ); // longest prefix wins assert_eq!( config.find_server_for_url("http://h:8080/graphs/spike/queries/q"), Some("dev-spike") ); // no substring trap: a different port/host must not match assert_eq!(config.find_server_for_url("http://h:8080-evil/x"), None); assert_eq!(config.find_server_for_url("http://other:9999"), None); } #[test] fn server_lookup_supports_targeting() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.yaml"); fs::write( &path, "servers:\n intel-dev:\n url: http://127.0.0.1:8080/\n", ) .unwrap(); let config = load_operator_config_at(&path).unwrap(); // the --server resolution shape: bare url and graph-scoped url let base = config.servers["intel-dev"].url.trim_end_matches('/'); assert_eq!(base, "http://127.0.0.1:8080"); assert_eq!( format!("{base}/graphs/spike"), "http://127.0.0.1:8080/graphs/spike" ); } #[test] fn token_env_name_uppercases_and_underscores() { assert_eq!(token_env_name("intel-dev"), "OMNIGRAPH_TOKEN_INTEL_DEV"); assert_eq!(token_env_name("prod"), "OMNIGRAPH_TOKEN_PROD"); } #[test] fn credentials_roundtrip_rotate_remove_preserving_other_sections() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("credentials"); rewrite_credentials_at(&path, "prod", Some("tok-1")).unwrap(); rewrite_credentials_at(&path, "dev", Some("tok-dev")).unwrap(); assert_eq!( read_credential_at(&path, "prod").unwrap().as_deref(), Some("tok-1") ); // rotate prod; dev preserved rewrite_credentials_at(&path, "prod", Some("tok-2")).unwrap(); assert_eq!( read_credential_at(&path, "prod").unwrap().as_deref(), Some("tok-2") ); assert_eq!( read_credential_at(&path, "dev").unwrap().as_deref(), Some("tok-dev") ); // remove prod; dev preserved; removal is idempotent rewrite_credentials_at(&path, "prod", None).unwrap(); rewrite_credentials_at(&path, "prod", None).unwrap(); assert_eq!(read_credential_at(&path, "prod").unwrap(), None); assert_eq!( read_credential_at(&path, "dev").unwrap().as_deref(), Some("tok-dev") ); } #[cfg(unix)] #[test] fn credentials_written_0600_and_over_permissive_refused() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("credentials"); rewrite_credentials_at(&path, "prod", Some("tok")).unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "written {:o}", mode & 0o777); fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); let err = read_credential_at(&path, "prod").unwrap_err(); assert!(err.to_string().contains("chmod 600"), "{err}"); } #[test] fn expand_tilde_resolves_home_prefix() { let home = env::home_dir().unwrap(); assert_eq!(expand_tilde("~"), home); assert_eq!(expand_tilde("~/x/y"), home.join("x/y")); assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); } }