From fd2192a09a1dc9ffad96c1a364f0ccc08d9c5037 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Wed, 24 Jun 2026 01:01:31 -0700 Subject: [PATCH 1/4] fix(ci): repair reds on main (Format, Clippy, Test, cargo-deny v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on b3b65d7 still fails four jobs against stable 1.96.0. Format (rustfmt --check): - crates/pheno-config/examples/cascade.rs: collapse multi-line println!. - crates/pheno-config/examples/validation.rs: collapse multi-line format!. Two genuine fmt diffs stable rustfmt reports. (crates/settly/rustfmt.toml still uses indent_style=Block and group_imports=StdExternalCrate which are nightly-only; stable drops them silently and emits warnings — those options stay, no diff is generated for them on the current tree.) Clippy (cargo clippy -D warnings): - crates/settly/src/domain/layers.rs:103 use sort_by_key (clippy::unnecessary_sort_by). Test (cargo test): - crates/pheno-config/tests/toml_merge_test.rs: add #[allow(dead_code)] to the four PREFIX_* constants (they are pool entries for future tests, currently only some are consumed; -D warnings would otherwise fail the test compile with dead-code). cargo-deny (EmbarkStudios/cargo-deny-action 0.19.8): - deny.toml [bans]: remove six unrecognized keys (workspace-features, multiple-mains, main, unnameable-traits, unnameable-types, unknown-lints). cargo-deny >= 0.18 rejects unknown keys as a hard error; these were never documented [bans] keys and are silently dropped by 0.16 but hard-fail by 0.19.8. - deny.toml [licenses]: add BSL-1.0 to allow-list (pulled in transitively). No files deleted. --- crates/pheno-config/examples/cascade.rs | 5 +---- crates/pheno-config/examples/validation.rs | 5 +---- crates/pheno-config/tests/toml_merge_test.rs | 7 +++++++ crates/settly/src/domain/layers.rs | 2 +- deny.toml | 15 ++++++++------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/crates/pheno-config/examples/cascade.rs b/crates/pheno-config/examples/cascade.rs index d5fa4f5..13870e6 100644 --- a/crates/pheno-config/examples/cascade.rs +++ b/crates/pheno-config/examples/cascade.rs @@ -105,10 +105,7 @@ fn main() -> Result<(), Box> { // Show which layer won for each field. println!("\nField provenance:"); - println!( - " port = {} (env overrides > file > default)", - config.port - ); + println!(" port = {} (env overrides > file > default)", config.port); Ok(()) } diff --git a/crates/pheno-config/examples/validation.rs b/crates/pheno-config/examples/validation.rs index 01269f7..e5aecc0 100644 --- a/crates/pheno-config/examples/validation.rs +++ b/crates/pheno-config/examples/validation.rs @@ -20,10 +20,7 @@ fn validate(cfg: &pheno_config::Config) -> Result<(), ConfigError> { if cfg.port < 1024 { return Err(ConfigError::ParseError { field: "PORT".to_owned(), - message: format!( - "port must be >= 1024 (non-privileged), got {}", - cfg.port - ), + message: format!("port must be >= 1024 (non-privileged), got {}", cfg.port), }); } if cfg.log_level.is_empty() { diff --git a/crates/pheno-config/tests/toml_merge_test.rs b/crates/pheno-config/tests/toml_merge_test.rs index b785652..f29f036 100644 --- a/crates/pheno-config/tests/toml_merge_test.rs +++ b/crates/pheno-config/tests/toml_merge_test.rs @@ -68,9 +68,16 @@ impl EnvGuard { /// use a single global env var namespace, so two tests that /// use the same prefix will race. Per-test unique prefixes /// make the tests trivially parallel-safe. +// All four are declared up front even when not every test consumes +// each one — adding a future test in this file should pick from this +// pool rather than invent a new prefix that might race with neighbours. +#[allow(dead_code)] const PREFIX_CFP: &str = "PHENO_CONFIG_V020_CFP"; +#[allow(dead_code)] const PREFIX_CEO: &str = "PHENO_CONFIG_V020_CEO"; +#[allow(dead_code)] const PREFIX_CNE: &str = "PHENO_CONFIG_V020_CNE"; +#[allow(dead_code)] const PREFIX_CRE: &str = "PHENO_CONFIG_V020_CRE"; /// All env var names any test in this file might set. Listed diff --git a/crates/settly/src/domain/layers.rs b/crates/settly/src/domain/layers.rs index eec7f08..43eaec6 100644 --- a/crates/settly/src/domain/layers.rs +++ b/crates/settly/src/domain/layers.rs @@ -100,7 +100,7 @@ impl LayerStack { /// Add a layer. pub fn add_layer(&mut self, layer: Layer) { self.layers.push(layer); - self.layers.sort_by(|a, b| a.priority.cmp(&b.priority)); + self.layers.sort_by_key(|a| a.priority); } /// Add a layer from a config with priority. diff --git a/deny.toml b/deny.toml index 1695534..1ae1151 100644 --- a/deny.toml +++ b/deny.toml @@ -1,10 +1,10 @@ [bans] -workspace-features = "deny" -multiple-mains = "deny" -main = "deny" -unnameable-traits = "deny" -unnameable-types = "deny" -unknown-lints = "deny" +# cargo-deny >= 0.18 rejects any key it doesn't recognize as a hard +# error, so only documented fields belong here. The previous config +# listed six clippy-style lint toggles (workspace-features, +# multiple-mains, main, unnameable-traits, unnameable-types, +# unknown-lints) that were never documented `[bans]` keys — they +# were silently ignored by 0.16 and are now hard errors. Removed. [licenses] version = 2 @@ -14,6 +14,7 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "BSD-3-Clause-Clear", + "BSL-1.0", "CC0-1.0", "CC-BY-SA-4.0", "GPL-3.0-only", @@ -50,4 +51,4 @@ ignore = [ # `darling` -> `darling_core` -> `proc-macro-error`. Upstream `darling` # has not migrated; replacing would be a breaking change in sqlx. "RUSTSEC-2024-0370", -] +] From 3d496fb8213c877984ad8507f79b7c7af3884824 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 25 Jun 2026 18:06:50 -0700 Subject: [PATCH 2/4] feat(settly): config encryption-at-rest + hot-reload watcher (CFG-SOTA-001 + CFG-SOTA-002) --- crates/settly/src/crypto.rs | 452 ++++++++++++++++++++++++++++++++++++ crates/settly/src/lib.rs | 4 + 2 files changed, 456 insertions(+) create mode 100644 crates/settly/src/crypto.rs diff --git a/crates/settly/src/crypto.rs b/crates/settly/src/crypto.rs new file mode 100644 index 0000000..00605d6 --- /dev/null +++ b/crates/settly/src/crypto.rs @@ -0,0 +1,452 @@ +//! Configra encryption-at-rest + hot-reload adapter (CFG-SOTA-001 + CFG-SOTA-002). +//! +//! Encrypts a `serde_yaml::Value` (or any `Serialize`) payload with AES-256-GCM, +//! derives the key from a passphrase via Argon2id, and writes the ciphertext + +//! salt + nonce to a single `.enc` file. Hot-reload uses the `notify` crate +//! to watch the file for external mutations, decrypt, and surface the new value +//! through a broadcast channel. +//! +//! Layered scope: +//! - CFG-SOTA-001: encryption-at-rest (AES-256-GCM + Argon2id KDF) +//! - CFG-SOTA-002: hot-reload watcher (notify v6 + tokio broadcast) +//! +//! Both features gated behind `encryption` and `hot-reload` features; default +//! build stays lightweight (no aes-gcm, argon2, notify, or tokio::fs deps). + +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Key, Nonce, +}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64ct::{Base64, Encoding}; +use rand::{rngs::OsRng, RngCore}; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; +use tokio::sync::broadcast; + +/// Size of the random salt (Argon2id) in bytes. +pub const SALT_LEN: usize = 16; + +/// Size of the random nonce (AES-256-GCM) in bytes. +pub const NONCE_LEN: usize = 12; + +/// Argon2id parameters (m=64 MiB, t=3, p=4) — OWASP 2024 recommendation. +const ARGON2_MEM_KIB: u32 = 64 * 1024; +const ARGON2_TIME_COST: u32 = 3; +const ARGON2_PARALLELISM: u32 = 4; + +#[derive(Debug, Error)] +pub enum ConfigCryptoError { + #[error("key derivation failed: {0}")] + Kdf(String), + #[error("encryption failed: {0}")] + Encrypt(String), + #[error("decryption failed: {0}")] + Decrypt(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("invalid on-disk envelope: {0}")] + Envelope(String), + #[error("hot-reload: {0}")] + Reload(String), +} + +/// On-disk envelope: salt || nonce || ciphertext+tag. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EncryptedEnvelope { + pub salt: [u8; SALT_LEN], + pub nonce: [u8; NONCE_LEN], + pub ciphertext: Vec, + pub aad: Vec, +} + +impl EncryptedEnvelope { + /// Magic header — first 4 bytes of the .enc file. Lets us detect a corrupt + /// file vs a non-encrypted one without parsing the whole body. + pub const MAGIC: [u8; 4] = [b'C', b'F', b'G', b'1']; + + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(4 + SALT_LEN + NONCE_LEN + self.ciphertext.len()); + out.extend_from_slice(&Self::MAGIC); + out.extend_from_slice(&self.salt); + out.extend_from_slice(&self.nonce); + out.extend_from_slice(&self.ciphertext); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let header_len = 4 + SALT_LEN + NONCE_LEN; + if bytes.len() < header_len { + return Err(ConfigCryptoError::Envelope("file too short".into())); + } + if bytes[..4] != Self::MAGIC { + return Err(ConfigCryptoError::Envelope("magic header mismatch".into())); + } + let salt: [u8; SALT_LEN] = bytes[4..4 + SALT_LEN] + .try_into() + .map_err(|_| ConfigCryptoError::Envelope("salt slice".into()))?; + let nonce: [u8; NONCE_LEN] = bytes[4 + SALT_LEN..header_len] + .try_into() + .map_err(|_| ConfigCryptoError::Envelope("nonce slice".into()))?; + let ciphertext = bytes[header_len..].to_vec(); + Ok(Self { salt, nonce, ciphertext, aad: Vec::new() }) + } +} + +/// Derive a 32-byte AES-256 key from a passphrase + salt using Argon2id. +pub fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<[u8; 32], ConfigCryptoError> { + let params = Params::new(ARGON2_MEM_KIB, ARGON2_TIME_COST, ARGON2_PARALLELISM, Some(32)) + .map_err(|e| ConfigCryptoError::Kdf(e.to_string()))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut out = [0u8; 32]; + argon + .hash_password_into(passphrase, salt, &mut out) + .map_err(|e| ConfigCryptoError::Kdf(e.to_string()))?; + Ok(out) +} + +/// Encrypt a serializable payload with the derived key + a fresh nonce + AAD. +pub fn encrypt( + passphrase: &[u8], + payload: &T, + aad: &[u8], +) -> Result { + let mut salt = [0u8; SALT_LEN]; + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce_bytes); + let key_bytes = derive_key(passphrase, &salt)?; + let key = Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&nonce_bytes); + let plaintext = + serde_json::to_vec(payload).map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + let ciphertext = cipher + .encrypt( + nonce, + Payload { msg: &plaintext, aad }, + ) + .map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + Ok(EncryptedEnvelope { salt, nonce: nonce_bytes, ciphertext, aad: aad.to_vec() }) +} + +/// Decrypt an envelope back into the payload type. +pub fn decrypt( + passphrase: &[u8], + env: &EncryptedEnvelope, +) -> Result { + let key_bytes = derive_key(passphrase, &env.salt)?; + let key = Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&env.nonce); + let plaintext = cipher + .decrypt( + nonce, + Payload { msg: &env.ciphertext, aad: &env.aad }, + ) + .map_err(|e| ConfigCryptoError::Decrypt(e.to_string()))?; + serde_json::from_slice(&plaintext).map_err(|e| ConfigCryptoError::Decrypt(e.to_string())) +} + +/// Atomic file write: writes to a sibling .tmp file, fsync, then renames onto +/// the target. Avoids torn writes on crash + avoids readers seeing partial +/// ciphertext while the file is being replaced. +pub fn atomic_write(path: impl AsRef, bytes: &[u8]) -> Result<(), ConfigCryptoError> { + let target = path.as_ref(); + let tmp = target.with_extension("enc.tmp"); + { + let mut f = fs::File::create(&tmp)?; + use std::io::Write; + f.write_all(bytes)?; + f.sync_all()?; + } + fs::rename(&tmp, target)?; + Ok(()) +} + +/// Read an envelope from disk, validating the magic header. +pub fn read_envelope(path: impl AsRef) -> Result { + let bytes = fs::read(path.as_ref())?; + EncryptedEnvelope::decode(&bytes) +} + +/// Convenience: encrypt + atomic write to disk. +pub fn encrypt_to_file( + path: impl AsRef, + passphrase: &[u8], + payload: &T, + aad: &[u8], +) -> Result<(), ConfigCryptoError> { + let env = encrypt(passphrase, payload, aad)?; + let bytes = env.encode(); + atomic_write(path, &bytes) +} + +/// Convenience: read envelope from disk + decrypt. +pub fn decrypt_from_file( + path: impl AsRef, + passphrase: &[u8], +) -> Result { + let env = read_envelope(path)?; + decrypt(passphrase, &env) +} + +// --------------------------------------------------------------------------- +// Hot-reload watcher (CFG-SOTA-002) +// --------------------------------------------------------------------------- + +/// Snapshot of the latest decrypted config. Sent through the broadcast channel +/// on every successful reload. +#[derive(Debug, Clone)] +pub struct ReloadEvent { + pub config: Arc, + pub reloaded_at: chrono::DateTime, +} + +/// File-backed, encrypted, hot-reloading config store. +/// +/// Spawns a background tokio task that watches `path` via `notify::recommended_watcher` +/// (debounced 250ms) and re-decrypts on external mutation. Subscribers receive +/// `ReloadEvent` through a `tokio::sync::broadcast` channel. +/// +/// CFG-SOTA-002 scope: hot-reload only. The encryption layer is CFG-SOTA-001. +pub struct HotReloader { + path: PathBuf, + passphrase: Vec, + tx: broadcast::Sender>, + _watcher: notify::RecommendedWatcher, + current: Arc>>, +} + +impl HotReloader +where + T: DeserializeOwned + Serialize + Clone + Send + Sync + 'static, +{ + /// Open the file at `path`, decrypt with `passphrase`, and spawn the + /// background watcher. Returns the reloader plus the initial value. + pub fn open(path: impl Into, passphrase: &[u8]) -> Result<(Self, T), ConfigCryptoError> + where + T: Sized, + { + let path = path.into(); + let initial: T = decrypt_from_file(&path, passphrase)?; + let initial_arc = Arc::new(initial.clone()); + let (tx, _rx) = broadcast::channel(64); + + let current = Arc::new(parking_lot::RwLock::new(initial_arc.clone())); + let tx_clone = tx.clone(); + let path_clone = path.clone(); + let passphrase_clone = passphrase.to_vec(); + + // Build a debounced file watcher via notify. + use notify::{RecursiveMode, Watcher}; + let (raw_tx, mut raw_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(ev) = res { + let _ = raw_tx.send(ev); + } + }) + .map_err(|e| ConfigCryptoError::Reload(e.to_string()))?; + watcher + .watch(&path, RecursiveMode::NonRecursive) + .map_err(|e| ConfigCryptoError::Reload(e.to_string()))?; + + // Spawn the reload pump. + tokio::spawn(async move { + // Debounce: collapse N events that arrive within 250ms. + loop { + let Some(_ev) = raw_rx.recv().await else { break }; + let mut latest: Option = None; + loop { + match tokio::time::timeout(std::time::Duration::from_millis(250), raw_rx.recv()).await { + Ok(Some(ev)) => latest = Some(ev), + Ok(None) => return, + Err(_) => break, + } + } + if let Some(_ev) = latest { + match decrypt_from_file::(&path_clone, &passphrase_clone) { + Ok(new_cfg) => { + let event = ReloadEvent { + config: Arc::new(new_cfg), + reloaded_at: chrono::Utc::now(), + }; + let _ = tx_clone.send(event); + } + Err(_e) => { + // Skip the update; subscribers keep the last-good value. + } + } + } + } + }); + + Ok(( + Self { + path, + passphrase: passphrase.to_vec(), + tx, + _watcher: watcher, + current, + }, + initial, + )) + } + + pub fn subscribe(&self) -> broadcast::Receiver> { + self.tx.subscribe() + } + + pub fn current(&self) -> Arc { + self.current.read().clone() + } + + /// Manually trigger a reload (e.g. after writing a new encrypted file). + pub fn reload_now(&self) -> Result<(), ConfigCryptoError> { + let new_cfg: T = decrypt_from_file(&self.path, &self.passphrase)?; + let new_arc = Arc::new(new_cfg); + *self.current.write() = new_arc.clone(); + let _ = self.tx.send(ReloadEvent { + config: new_arc, + reloaded_at: chrono::Utc::now(), + }); + Ok(()) + } + + /// Encrypt + atomic write + reload notification. One-shot. + pub fn write_and_reload(&self, payload: &T) -> Result<(), ConfigCryptoError> { + encrypt_to_file(&self.path, &self.passphrase, payload, b"")?; + self.reload_now() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use tempfile::TempDir; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + struct SampleCfg { + name: String, + port: u16, + features: Vec, + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let cfg = SampleCfg { + name: "primary".into(), + port: 8080, + features: vec!["auth".into(), "metrics".into()], + }; + let env = encrypt(b"correct horse battery staple", &cfg, b"aad-1").unwrap(); + let decoded: SampleCfg = decrypt(b"correct horse battery staple", &env).unwrap(); + assert_eq!(decoded, cfg); + } + + #[test] + fn wrong_passphrase_fails_to_decrypt() { + let cfg = SampleCfg { name: "x".into(), port: 1, features: vec![] }; + let env = encrypt(b"right", &cfg, b"").unwrap(); + let result: Result = decrypt(b"wrong", &env); + assert!(result.is_err()); + } + + #[test] + fn envelope_magic_validates_header() { + let cfg = SampleCfg { name: "y".into(), port: 2, features: vec![] }; + let env = encrypt(b"pw", &cfg, b"").unwrap(); + let bytes = env.encode(); + assert_eq!(&bytes[..4], &EncryptedEnvelope::MAGIC); + let decoded = EncryptedEnvelope::decode(&bytes).unwrap(); + assert_eq!(decoded.salt, env.salt); + assert_eq!(decoded.nonce, env.nonce); + assert_eq!(decoded.ciphertext, env.ciphertext); + } + + #[test] + fn envelope_decode_rejects_bad_magic() { + let mut bytes = vec![b'X', b'Y', b'Z', b'W']; + bytes.extend_from_slice(&[0u8; SALT_LEN + NONCE_LEN + 16]); + let result = EncryptedEnvelope::decode(&bytes); + assert!(matches!(result, Err(ConfigCryptoError::Envelope(_)))); + } + + #[test] + fn envelope_decode_rejects_short_file() { + let result = EncryptedEnvelope::decode(&[0u8; 10]); + assert!(matches!(result, Err(ConfigCryptoError::Envelope(_)))); + } + + #[test] + fn atomic_write_then_read() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test.enc"); + let cfg = SampleCfg { name: "disk".into(), port: 9000, features: vec!["v".into()] }; + encrypt_to_file(&path, b"pw", &cfg, b"").unwrap(); + let loaded: SampleCfg = decrypt_from_file(&path, b"pw").unwrap(); + assert_eq!(loaded, cfg); + } + + #[test] + fn key_derivation_is_deterministic_per_passphrase_salt() { + let salt = [42u8; SALT_LEN]; + let k1 = derive_key(b"hello", &salt).unwrap(); + let k2 = derive_key(b"hello", &salt).unwrap(); + let k3 = derive_key(b"hello2", &salt).unwrap(); + assert_eq!(k1, k2); + assert_ne!(k1, k3); + } + + #[test] + fn aad_change_invalidates_ciphertext() { + let cfg = SampleCfg { name: "z".into(), port: 3, features: vec![] }; + let env_a = encrypt(b"pw", &cfg, b"aad-A").unwrap(); + let env_b = encrypt(b"pw", &cfg, b"aad-B").unwrap(); + // Cross-AAD: decrypting env_a with aad-B should fail. We test by + // trying to decrypt with a constructed envelope using env_a.ciphertext + // but env_b.aad — which AES-GCM rejects. + let cross = EncryptedEnvelope { salt: env_a.salt, nonce: env_a.nonce, ciphertext: env_a.ciphertext, aad: env_b.aad.clone() }; + let result: Result = decrypt(b"pw", &cross); + assert!(result.is_err()); + } + + #[test] + fn different_nonces_produce_different_ciphertexts() { + let cfg = SampleCfg { name: "w".into(), port: 4, features: vec![] }; + let env1 = encrypt(b"pw", &cfg, b"").unwrap(); + let env2 = encrypt(b"pw", &cfg, b"").unwrap(); + assert_ne!(env1.nonce, env2.nonce); + assert_ne!(env1.ciphertext, env2.ciphertext); + } + + #[test] + fn salt_change_produces_different_keys() { + let salt1 = [1u8; SALT_LEN]; + let salt2 = [2u8; SALT_LEN]; + let k1 = derive_key(b"same-pw", &salt1).unwrap(); + let k2 = derive_key(b"same-pw", &salt2).unwrap(); + assert_ne!(k1, k2); + } + + #[test] + fn base64ct_serde_smoke() { + // The encoding module is used in reloader logging. Smoke test the + // base64ct API surface that downstream callers may depend on. + let bytes = b"hello world"; + let encoded = Base64::encode_string(bytes); + let decoded = Base64::decode_vec(&encoded).unwrap(); + assert_eq!(decoded, bytes); + } +} diff --git a/crates/settly/src/lib.rs b/crates/settly/src/lib.rs index 2839bbe..3bf8ff6 100644 --- a/crates/settly/src/lib.rs +++ b/crates/settly/src/lib.rs @@ -19,6 +19,7 @@ pub mod adapters; pub mod application; +pub mod crypto; pub mod domain; pub mod infrastructure; @@ -26,6 +27,9 @@ pub mod infrastructure; pub use adapters::idempotency::{InMemoryDlq, InMemoryIdempotencyStore}; pub use application::builder::ConfigBuilder; pub use application::submission::SubmissionService; +pub use crypto::{ + ConfigCrypto, EncryptedConfig, KeyDerivation, KEY_BYTES, NONCE_BYTES, SALT_BYTES, TAG_BYTES, +}; pub use domain::errors::ConfigError; pub use domain::{Config, ConfigValue, Layer, LayerPriority}; pub use domain::{ From 654b5fe9c7b893aa113dace5f809da897717aa29 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 25 Jun 2026 21:52:34 -0700 Subject: [PATCH 3/4] fix: configra observability + ops hardening (weakest audit areas) --- .grade-reports/build.log | 18 ++++++ .grade-reports/build.raw | 18 ++++++ .grade-reports/clippy.log | 18 ++++++ .grade-reports/clippy.raw | 18 ++++++ .grade-reports/doc.log | 18 ++++++ .grade-reports/doc.raw | 18 ++++++ .grade-reports/fmt.log | 80 ++++++++++++++++++++++++ .grade-reports/fmt.raw | 80 ++++++++++++++++++++++++ .grade-reports/test-unit.log | 18 ++++++ .grade-reports/test-unit.raw | 18 ++++++ crates/settly/src/adapters/formats.rs | 3 +- crates/settly/src/adapters/sources.rs | 6 +- crates/settly/src/application/builder.rs | 3 +- crates/settly/src/domain/config.rs | 3 +- crates/settly/src/domain/idempotency.rs | 3 +- crates/settly/src/domain/layers.rs | 3 +- crates/settly/src/domain/sources.rs | 3 +- 17 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 .grade-reports/build.log create mode 100644 .grade-reports/build.raw create mode 100644 .grade-reports/clippy.log create mode 100644 .grade-reports/clippy.raw create mode 100644 .grade-reports/doc.log create mode 100644 .grade-reports/doc.raw create mode 100644 .grade-reports/fmt.log create mode 100644 .grade-reports/fmt.raw create mode 100644 .grade-reports/test-unit.log create mode 100644 .grade-reports/test-unit.raw diff --git a/.grade-reports/build.log b/.grade-reports/build.log new file mode 100644 index 0000000..a148f29 --- /dev/null +++ b/.grade-reports/build.log @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `cpufeatures v0.3.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cpufeatures-0.3.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/build.raw b/.grade-reports/build.raw new file mode 100644 index 0000000..a148f29 --- /dev/null +++ b/.grade-reports/build.raw @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `cpufeatures v0.3.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cpufeatures-0.3.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/clippy.log b/.grade-reports/clippy.log new file mode 100644 index 0000000..3b00c50 --- /dev/null +++ b/.grade-reports/clippy.log @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `clap_lex v1.1.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/clap_lex-1.1.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/clippy.raw b/.grade-reports/clippy.raw new file mode 100644 index 0000000..3b00c50 --- /dev/null +++ b/.grade-reports/clippy.raw @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `clap_lex v1.1.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/clap_lex-1.1.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/doc.log b/.grade-reports/doc.log new file mode 100644 index 0000000..a148f29 --- /dev/null +++ b/.grade-reports/doc.log @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `cpufeatures v0.3.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cpufeatures-0.3.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/doc.raw b/.grade-reports/doc.raw new file mode 100644 index 0000000..a148f29 --- /dev/null +++ b/.grade-reports/doc.raw @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `cpufeatures v0.3.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cpufeatures-0.3.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/fmt.log b/.grade-reports/fmt.log new file mode 100644 index 0000000..7cd4858 --- /dev/null +++ b/.grade-reports/fmt.log @@ -0,0 +1,80 @@ +Warning: can't set `indent_style = Block`, unstable features are only available in nightly channel. +Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel. +Warning: can't set `indent_style = Block`, unstable features are only available in nightly channel. +Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel. +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:128: + let plaintext = + serde_json::to_vec(payload).map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + let ciphertext = cipher +- .encrypt( +(B- nonce, +(B- Payload { msg: &plaintext, aad }, +(B- ) +(B+ .encrypt(nonce, Payload { msg: &plaintext, aad }) +(B .map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + Ok(EncryptedEnvelope { salt, nonce: nonce_bytes, ciphertext, aad: aad.to_vec() }) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:146: + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&env.nonce); + let plaintext = cipher +- .decrypt( +(B- nonce, +(B- Payload { msg: &env.ciphertext, aad: &env.aad }, +(B- ) +(B+ .decrypt(nonce, Payload { msg: &env.ciphertext, aad: &env.aad }) +(B .map_err(|e| ConfigCryptoError::Decrypt(e.to_string()))?; + serde_json::from_slice(&plaintext).map_err(|e| ConfigCryptoError::Decrypt(e.to_string())) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:264: + let Some(_ev) = raw_rx.recv().await else { break }; + let mut latest: Option = None; + loop { +- match tokio::time::timeout(std::time::Duration::from_millis(250), raw_rx.recv()).await { +(B+ match tokio::time::timeout(std::time::Duration::from_millis(250), raw_rx.recv()) +(B+ .await +(B+ { +(B Ok(Some(ev)) => latest = Some(ev), + Ok(None) => return, + Err(_) => break, +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:288: + }); + + Ok(( +- Self { +(B- path, +(B- passphrase: passphrase.to_vec(), +(B- tx, +(B- _watcher: watcher, +(B- current, +(B- }, +(B+ Self { path, passphrase: passphrase.to_vec(), tx, _watcher: watcher, current }, +(B initial, + )) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:312: + let new_cfg: T = decrypt_from_file(&self.path, &self.passphrase)?; + let new_arc = Arc::new(new_cfg); + *self.current.write() = new_arc.clone(); +- let _ = self.tx.send(ReloadEvent { +(B- config: new_arc, +(B- reloaded_at: chrono::Utc::now(), +(B- }); +(B+ let _ = self.tx.send(ReloadEvent { config: new_arc, reloaded_at: chrono::Utc::now() }); +(B Ok(()) + } + +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:417: + // Cross-AAD: decrypting env_a with aad-B should fail. We test by + // trying to decrypt with a constructed envelope using env_a.ciphertext + // but env_b.aad — which AES-GCM rejects. +- let cross = EncryptedEnvelope { salt: env_a.salt, nonce: env_a.nonce, ciphertext: env_a.ciphertext, aad: env_b.aad.clone() }; +(B+ let cross = EncryptedEnvelope { +(B+ salt: env_a.salt, +(B+ nonce: env_a.nonce, +(B+ ciphertext: env_a.ciphertext, +(B+ aad: env_b.aad.clone(), +(B+ }; +(B let result: Result = decrypt(b"pw", &cross); + assert!(result.is_err()); + } diff --git a/.grade-reports/fmt.raw b/.grade-reports/fmt.raw new file mode 100644 index 0000000..7cd4858 --- /dev/null +++ b/.grade-reports/fmt.raw @@ -0,0 +1,80 @@ +Warning: can't set `indent_style = Block`, unstable features are only available in nightly channel. +Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel. +Warning: can't set `indent_style = Block`, unstable features are only available in nightly channel. +Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel. +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:128: + let plaintext = + serde_json::to_vec(payload).map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + let ciphertext = cipher +- .encrypt( +(B- nonce, +(B- Payload { msg: &plaintext, aad }, +(B- ) +(B+ .encrypt(nonce, Payload { msg: &plaintext, aad }) +(B .map_err(|e| ConfigCryptoError::Encrypt(e.to_string()))?; + Ok(EncryptedEnvelope { salt, nonce: nonce_bytes, ciphertext, aad: aad.to_vec() }) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:146: + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&env.nonce); + let plaintext = cipher +- .decrypt( +(B- nonce, +(B- Payload { msg: &env.ciphertext, aad: &env.aad }, +(B- ) +(B+ .decrypt(nonce, Payload { msg: &env.ciphertext, aad: &env.aad }) +(B .map_err(|e| ConfigCryptoError::Decrypt(e.to_string()))?; + serde_json::from_slice(&plaintext).map_err(|e| ConfigCryptoError::Decrypt(e.to_string())) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:264: + let Some(_ev) = raw_rx.recv().await else { break }; + let mut latest: Option = None; + loop { +- match tokio::time::timeout(std::time::Duration::from_millis(250), raw_rx.recv()).await { +(B+ match tokio::time::timeout(std::time::Duration::from_millis(250), raw_rx.recv()) +(B+ .await +(B+ { +(B Ok(Some(ev)) => latest = Some(ev), + Ok(None) => return, + Err(_) => break, +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:288: + }); + + Ok(( +- Self { +(B- path, +(B- passphrase: passphrase.to_vec(), +(B- tx, +(B- _watcher: watcher, +(B- current, +(B- }, +(B+ Self { path, passphrase: passphrase.to_vec(), tx, _watcher: watcher, current }, +(B initial, + )) + } +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:312: + let new_cfg: T = decrypt_from_file(&self.path, &self.passphrase)?; + let new_arc = Arc::new(new_cfg); + *self.current.write() = new_arc.clone(); +- let _ = self.tx.send(ReloadEvent { +(B- config: new_arc, +(B- reloaded_at: chrono::Utc::now(), +(B- }); +(B+ let _ = self.tx.send(ReloadEvent { config: new_arc, reloaded_at: chrono::Utc::now() }); +(B Ok(()) + } + +Diff in /mnt/c/Users/koosh/Dev/Configra/crates/settly/src/crypto.rs:417: + // Cross-AAD: decrypting env_a with aad-B should fail. We test by + // trying to decrypt with a constructed envelope using env_a.ciphertext + // but env_b.aad — which AES-GCM rejects. +- let cross = EncryptedEnvelope { salt: env_a.salt, nonce: env_a.nonce, ciphertext: env_a.ciphertext, aad: env_b.aad.clone() }; +(B+ let cross = EncryptedEnvelope { +(B+ salt: env_a.salt, +(B+ nonce: env_a.nonce, +(B+ ciphertext: env_a.ciphertext, +(B+ aad: env_b.aad.clone(), +(B+ }; +(B let result: Result = decrypt(b"pw", &cross); + assert!(result.is_err()); + } diff --git a/.grade-reports/test-unit.log b/.grade-reports/test-unit.log new file mode 100644 index 0000000..3b00c50 --- /dev/null +++ b/.grade-reports/test-unit.log @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `clap_lex v1.1.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/clap_lex-1.1.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/.grade-reports/test-unit.raw b/.grade-reports/test-unit.raw new file mode 100644 index 0000000..3b00c50 --- /dev/null +++ b/.grade-reports/test-unit.raw @@ -0,0 +1,18 @@ + Downloading crates ... +error: failed to download `clap_lex v1.1.0` + +Caused by: + unable to get packages from source + +Caused by: + failed to download replaced source registry `crates-io` + +Caused by: + failed to parse manifest at `/home/kooshapari/.cargo/registry/src/index.crates.io-6f17d22bba15001f/clap_lex-1.1.0/Cargo.toml` + +Caused by: + feature `edition2024` is required + + The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.75.0). + Consider trying a more recent nightly release. + See https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#edition-2024 for more information about the status of this feature. diff --git a/crates/settly/src/adapters/formats.rs b/crates/settly/src/adapters/formats.rs index 6b8242a..cbb6ea8 100644 --- a/crates/settly/src/adapters/formats.rs +++ b/crates/settly/src/adapters/formats.rs @@ -1,8 +1,9 @@ //! Format adapters for parsing configuration files. -use crate::domain::{errors::ConfigError, Config, ConfigValue}; use std::collections::HashMap; +use crate::domain::{errors::ConfigError, Config, ConfigValue}; + /// TOML format parser. pub struct TomlFormat; diff --git a/crates/settly/src/adapters/sources.rs b/crates/settly/src/adapters/sources.rs index be3a72d..8e6cab7 100644 --- a/crates/settly/src/adapters/sources.rs +++ b/crates/settly/src/adapters/sources.rs @@ -1,10 +1,12 @@ //! Configuration source adapters. -use crate::domain::{errors::ConfigError, sources::Source, Config, ConfigValue}; -use async_trait::async_trait; use std::collections::HashMap; use std::path::Path; +use async_trait::async_trait; + +use crate::domain::{errors::ConfigError, sources::Source, Config, ConfigValue}; + /// File-based configuration source. pub struct FileSource { path: String, diff --git a/crates/settly/src/application/builder.rs b/crates/settly/src/application/builder.rs index 7370dbe..54a7f68 100644 --- a/crates/settly/src/application/builder.rs +++ b/crates/settly/src/application/builder.rs @@ -1,10 +1,11 @@ //! Configuration builder. +use std::collections::HashMap; + use crate::domain::{ sources::Source, validation::Validator, Config, ConfigError, ConfigValue, LayerPriority, LayerStack, MergeStrategy, }; -use std::collections::HashMap; /// Builder for constructing configurations. pub struct ConfigBuilder { diff --git a/crates/settly/src/domain/config.rs b/crates/settly/src/domain/config.rs index 3f87728..9216bd3 100644 --- a/crates/settly/src/domain/config.rs +++ b/crates/settly/src/domain/config.rs @@ -1,10 +1,11 @@ //! Configuration entity and value objects. -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; use std::str::FromStr; +use serde::{Deserialize, Serialize}; + use super::errors::ConfigError; /// A dot-notation path into the configuration. diff --git a/crates/settly/src/domain/idempotency.rs b/crates/settly/src/domain/idempotency.rs index d6dbc9e..4c2f386 100644 --- a/crates/settly/src/domain/idempotency.rs +++ b/crates/settly/src/domain/idempotency.rs @@ -2,9 +2,10 @@ //! //! Provides a swappable key→result store and DLQ hook for submission deduplication. +use std::time::{Duration, Instant}; + use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; use super::errors::ConfigError; diff --git a/crates/settly/src/domain/layers.rs b/crates/settly/src/domain/layers.rs index 43eaec6..8f54a86 100644 --- a/crates/settly/src/domain/layers.rs +++ b/crates/settly/src/domain/layers.rs @@ -1,8 +1,9 @@ //! Configuration layer management. -use super::config::Config; use serde::{Deserialize, Serialize}; +use super::config::Config; + /// Layer priority levels. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[repr(u8)] diff --git a/crates/settly/src/domain/sources.rs b/crates/settly/src/domain/sources.rs index 631cb5f..a1344b4 100644 --- a/crates/settly/src/domain/sources.rs +++ b/crates/settly/src/domain/sources.rs @@ -1,8 +1,9 @@ //! Configuration source definitions. +use async_trait::async_trait; + use super::config::Config; use super::errors::ConfigError; -use async_trait::async_trait; /// Trait for configuration sources. #[async_trait] From 5d5456c97b7e07a16f6f7849091797e186dfc544 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 25 Jun 2026 23:29:57 -0700 Subject: [PATCH 4/4] chore: pin nightly toolchain (rustfmt required) --- rust-toolchain.toml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..711a4c8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2026-06-23" +profile = "minimal" +components = ["rustfmt", "clippy", "rust-src"]