diff --git a/Cargo.lock b/Cargo.lock index 9c2dd94f3..071813572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,16 +28,16 @@ dependencies = [ "open", "p256", "predicates", - "rand 0.8.5", + "rand 0.8.8", "ratatui", "regex", "reqwest", "rsa", "semver", "serde", + "serde-saphyr", "serde_json", "serde_yaml", - "serde_yml", "sha2", "similar", "streaming-iterator", @@ -93,7 +93,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -120,6 +120,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96401ca08501972288ecbcde33902fce858bf73fbcbdf91dab8c3a9544e106bb" +dependencies = [ + "anstyle", + "unicode-width 0.2.0", +] + [[package]] name = "anstream" version = "1.0.0" @@ -193,10 +203,16 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.6" @@ -419,7 +435,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -429,7 +456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -639,6 +666,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -757,6 +793,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -968,6 +1031,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -986,6 +1073,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1089,6 +1177,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1307,10 +1401,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1343,6 +1440,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "granit-parser" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da55feca5d1625466bae068f9fd4b87892caeae70c3fe671ddb0f50d41aebf37" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "grep-matcher" version = "0.1.8" @@ -1437,6 +1544,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1904,17 +2020,26 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", - "ring", + "rand 0.8.8", + "rsa", "serde", "serde_json", + "sha2", + "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -1986,16 +2111,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "line-clipping" version = "0.3.5" @@ -2178,7 +2293,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -2221,6 +2336,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -2267,7 +2388,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2393,6 +2514,18 @@ dependencies = [ "sha2", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2547,7 +2680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -2630,7 +2763,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2775,14 +2908,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.1", "lru-slab", - "rand 0.9.2", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -2825,9 +2959,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2836,14 +2970,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2882,6 +3027,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -3151,9 +3311,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -3175,9 +3335,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -3277,6 +3437,23 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253edfdea34032da21af84e16fcc94f4c7a6ba237f9f3bc2dbf43cea8535c44" +dependencies = [ + "annotate-snippets", + "base64", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3354,21 +3531,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "serde_yml" -version = "0.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" -dependencies = [ - "indexmap", - "itoa", - "libyml", - "memchr", - "ryu", - "serde", - "version_check", -] - [[package]] name = "serial2" version = "0.2.34" @@ -3387,7 +3549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3398,7 +3560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3683,7 +3845,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand 0.8.5", + "rand 0.8.8", "regex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index addce55a3..8e5b4c6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ url = "2" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yml = "0.0.12" +serde-saphyr = { version = "2", default-features = false, features = ["serialize", "deserialize"] } serde_yaml = "0.9" toml = "1.1" regex = "1" @@ -81,7 +81,7 @@ chacha20poly1305 = "0.10" argon2 = "0.5" # JWS signing for the headless RFC 7523 jwt-bearer client (RS256 / ES256) -jsonwebtoken = "9" +jsonwebtoken = { version = "10.4", features = ["rust_crypto"] } # Async utilities futures = "0.3" diff --git a/src/analysis/cache.rs b/src/analysis/cache.rs index 9a03315b6..f8d79bac4 100644 --- a/src/analysis/cache.rs +++ b/src/analysis/cache.rs @@ -176,7 +176,7 @@ pub fn get_git_branch(repo_path: &Path) -> Option { /// /// When this returns `true`, the caller should skip persisting the cache entry. fn is_analysis_cache_oversized(cached: &CachedAnalysis) -> bool { - let size_result = serde_yml::to_string(cached) + let size_result = crate::yaml::to_string(cached) .map(|s| s.len()) .map_err(|e| e.to_string()); is_analysis_cache_oversized_inner(size_result) @@ -1031,7 +1031,7 @@ cached_analysis: analyzed_at: "2024-01-01T00:00:00Z" "#; - let cfg: config::Config = serde_yml::from_str(yaml).expect("should parse old cache YAML"); + let cfg: config::Config = crate::yaml::from_str(yaml).expect("should parse old cache YAML"); let cached = cfg.cached_analysis.expect("should have cached_analysis"); assert_eq!(cached.repo_path, "/home/user/project"); assert_eq!(cached.head_commit.as_deref(), Some("abc123def456")); @@ -1141,7 +1141,7 @@ cached_analysis: }; // Sanity-check the fixture. - let size = serde_yml::to_string(&big_cached) + let size = crate::yaml::to_string(&big_cached) .map(|s| s.len()) .unwrap_or(0); assert!(size > CACHE_MAX_SIZE_BYTES, "fixture must exceed limit"); @@ -1211,7 +1211,9 @@ cached_analysis: analysis: big_analysis, analyzed_at: chrono::Utc::now(), }; - let size = serde_yml::to_string(&cached).map(|s| s.len()).unwrap_or(0); + let size = crate::yaml::to_string(&cached) + .map(|s| s.len()) + .unwrap_or(0); assert!( size > CACHE_MAX_SIZE_BYTES, "test fixture must exceed the limit ({size} bytes vs {CACHE_MAX_SIZE_BYTES} bytes)" diff --git a/src/analysis/static_analyzer/monorepo.rs b/src/analysis/static_analyzer/monorepo.rs index d4dd118bb..0e891f5c9 100644 --- a/src/analysis/static_analyzer/monorepo.rs +++ b/src/analysis/static_analyzer/monorepo.rs @@ -96,7 +96,7 @@ fn detect_pnpm(root: &Path) -> Result, std::io::Error> { packages: Option>, } - let workspace: PnpmWorkspace = serde_yml::from_str(&content).map_err(|e| { + let workspace: PnpmWorkspace = crate::yaml::from_repo_str(&content).map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, format!("invalid pnpm-workspace.yaml: {e}"), diff --git a/src/auth/jwt_bearer.rs b/src/auth/jwt_bearer.rs index eac618953..1f7002467 100644 --- a/src/auth/jwt_bearer.rs +++ b/src/auth/jwt_bearer.rs @@ -461,12 +461,8 @@ mod tests { /// Decode an assertion's claims WITHOUT checking the signature or the time /// claims — for tests that only inspect `jti`/`exp`/`iat` values. - fn decode_claims_only(assertion: &str, key: &DecodingKey, alg: Algorithm) -> AssertionClaims { - let mut validation = Validation::new(alg); - validation.insecure_disable_signature_validation(); - validation.validate_exp = false; - validation.validate_aud = false; - decode::(assertion, key, &validation) + fn decode_claims_only(assertion: &str) -> AssertionClaims { + jsonwebtoken::dangerous::insecure_decode::(assertion) .expect("decode claims") .claims } @@ -618,7 +614,7 @@ mod tests { #[test] fn each_assertion_gets_a_fresh_unique_jti() { - let (priv_pem, pub_pem) = ec_keypair(); + let (priv_pem, _pub) = ec_keypair(); let a = build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) .unwrap(); @@ -626,9 +622,8 @@ mod tests { build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) .unwrap(); - let key = DecodingKey::from_ec_pem(&pub_pem).unwrap(); - let ja = decode_claims_only(&a, &key, Algorithm::ES256).jti; - let jb = decode_claims_only(&b, &key, Algorithm::ES256).jti; + let ja = decode_claims_only(&a).jti; + let jb = decode_claims_only(&b).jti; assert_ne!( ja, jb, "each assertion must carry a unique jti (anti-replay)" @@ -637,14 +632,13 @@ mod tests { #[test] fn lifetime_is_clamped_to_the_server_cap() { - let (priv_pem, pub_pem) = ec_keypair(); + let (priv_pem, _pub) = ec_keypair(); let mut p = params(AssertionAlgorithm::Es256); p.lifetime_seconds = 10_000; // absurd; must clamp to 300 let now = 1_700_000_000; let assertion = build_and_sign_assertion(&p, &priv_pem, now).unwrap(); - let key = DecodingKey::from_ec_pem(&pub_pem).unwrap(); - let claims = decode_claims_only(&assertion, &key, Algorithm::ES256); + let claims = decode_claims_only(&assertion); assert_eq!(claims.exp - claims.iat, MAX_ASSERTION_LIFETIME_SECONDS); } diff --git a/src/auth/store.rs b/src/auth/store.rs index 01aaef392..7bca6cf04 100644 --- a/src/auth/store.rs +++ b/src/auth/store.rs @@ -134,7 +134,7 @@ pub fn load_from(path: &Path) -> Result, ActualError> let contents = String::from_utf8(bytes).map_err(|e| { credentials_error(format!("Credentials file is not valid UTF-8: {e}")) })?; - let creds = serde_yml::from_str(&contents) + let creds = crate::yaml::from_str(&contents) .map_err(|e| credentials_error(format!("Failed to parse credentials: {e}")))?; Ok(Some(creds)) } @@ -157,7 +157,7 @@ pub fn save_to(creds: &StoredCredentials, path: &Path) -> Result<(), ActualError std::fs::create_dir_all(parent) .map_err(|e| credentials_error(format!("Failed to create config directory: {e}")))?; } - let yaml = serde_yml::to_string(creds) + let yaml = crate::yaml::to_string(creds) .map_err(|e| credentials_error(format!("Failed to serialize credentials: {e}")))?; write_secure(path, &yaml) } @@ -491,4 +491,25 @@ mod tests { let err = save_to(&sample(), Path::new("/")).unwrap_err(); assert!(err.to_string().contains("Failed to"), "got: {err}"); } + + /// A credentials file serde_yml 0.0.12 wrote reads back unchanged. + #[test] + fn test_reads_credentials_written_by_serde_yml() { + let dir = tempdir().unwrap(); + let path = dir.path().join("credentials.yaml"); + std::fs::write( + &path, + include_str!("../../tests/fixtures/serde_yml/credentials.yaml"), + ) + .unwrap(); + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/serde_yml/credentials.json" + )) + .unwrap(); + let creds = load_from(&path).unwrap().expect("credentials present"); + assert_eq!(serde_json::to_value(&creds).unwrap(), expected); + + save_to(&creds, &path).unwrap(); + assert_eq!(load_from(&path).unwrap(), Some(creds)); + } } diff --git a/src/cli/commands/config.rs b/src/cli/commands/config.rs index 6787ae506..c5c9951ad 100644 --- a/src/cli/commands/config.rs +++ b/src/cli/commands/config.rs @@ -44,7 +44,7 @@ fn run_with_resolver( } fn serialize_config_for_display(cfg: &config::Config) -> Result { - serde_yml::to_string(cfg) + crate::yaml::to_string(cfg) .map_err(|e| ActualError::ConfigError(format!("Failed to serialize config to YAML: {e}"))) } @@ -561,7 +561,7 @@ mod tests { #[test] fn test_show_after_setting_api_key_redacts_in_yaml() { // Verify that the redact_yaml function correctly handles the YAML that - // serde_yml would produce for a set API key. + // the config serializer would produce for a set API key. let yaml_with_key = "anthropic_api_key: sk-ant-actual-key\nmodel: ~\n"; let redacted = redact_yaml(yaml_with_key); assert!( @@ -707,4 +707,26 @@ mod tests { "show should succeed when no API keys are set" ); } + + /// `config show` redacts line by line, so it depends on the serializer + /// keeping each key's value on the key's own line, long values included. + #[test] + fn test_show_redacts_long_and_quoted_api_keys() { + let long = format!("sk-ant-api03-{}", "x".repeat(300)); + let quoted = "secret: with #hash, 'single' and \"double\" quotes ".repeat(6); + let cfg = config::Config { + anthropic_api_key: Some(long.clone()), + openai_api_key: Some(quoted.clone()), + cursor_api_key: Some("cursor-secret".to_string()), + ..config::Config::default() + }; + let shown = redact_yaml(&serialize_config_for_display(&cfg).unwrap()); + for fragment in ["xxxxxxxxxx", "#hash", "'single'", "double", "cursor-secret"] { + assert!( + !shown.contains(fragment), + "{fragment} leaked into:\n{shown}" + ); + } + assert_eq!(shown.matches("[redacted]").count(), 3, "got:\n{shown}"); + } } diff --git a/src/cli/commands/sync/cache.rs b/src/cli/commands/sync/cache.rs index 344bfa507..3129ab938 100644 --- a/src/cli/commands/sync/cache.rs +++ b/src/cli/commands/sync/cache.rs @@ -83,7 +83,7 @@ pub(crate) fn store_tailoring_cache( root_dir: &Path, output: &TailoringOutput, ) { - let size_result = serde_yml::to_string(output) + let size_result = crate::yaml::to_string(output) .map(|s| s.len()) .map_err(|e| e.to_string()); store_tailoring_cache_inner(config, cfg_path, cache_key, root_dir, output, size_result); diff --git a/src/cli/commands/sync/pipeline.rs b/src/cli/commands/sync/pipeline.rs index 91d566a11..d02e8ff06 100644 --- a/src/cli/commands/sync/pipeline.rs +++ b/src/cli/commands/sync/pipeline.rs @@ -4538,8 +4538,8 @@ mod tests { }; // Serialize to YAML and back - let yaml = serde_yml::to_string(&cached).unwrap(); - let restored: CachedTailoring = serde_yml::from_str(&yaml).unwrap(); + let yaml = crate::yaml::to_string(&cached).unwrap(); + let restored: CachedTailoring = crate::yaml::from_str(&yaml).unwrap(); assert_eq!(cached.cache_key, restored.cache_key); assert_eq!(cached.tailoring, restored.tailoring); } @@ -4550,7 +4550,7 @@ mod tests { // miss) rather than failing to parse the entire Config. use crate::config::Config; let yaml = "cached_tailoring:\n garbage_field: some_value\n"; - let cfg: Config = serde_yml::from_str(yaml).expect("config must parse despite bad cache"); + let cfg: Config = crate::yaml::from_str(yaml).expect("config must parse despite bad cache"); assert!( cfg.cached_tailoring.is_none(), "corrupt cached_tailoring should be treated as absent" diff --git a/src/config/paths.rs b/src/config/paths.rs index 8aa758a0f..d1e301d0f 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -100,7 +100,7 @@ pub fn load_from(path: &Path) -> Result { } let contents = String::from_utf8(bytes) .map_err(|e| config_error(format!("Config file is not valid UTF-8: {e}")))?; - serde_yml::from_str(&contents) + crate::yaml::from_str(&contents) .map_err(|e| config_error(format!("Failed to parse config YAML: {e}"))) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -130,7 +130,7 @@ pub fn save_to(config: &Config, path: &Path) -> Result<(), ActualError> { .map_err(|e| config_error(format!("Failed to create config directory: {e}")))?; } - let yaml = serde_yml::to_string(config) + let yaml = crate::yaml::to_string(config) .map_err(|e| config_error(format!("Failed to serialize config to YAML: {e}")))?; write_config_secure(path, &yaml)?; diff --git a/src/config/types.rs b/src/config/types.rs index 22d184963..a4004ec02 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -359,8 +359,8 @@ fn deserialize_optional_cached_analysis<'de, D>( where D: serde::Deserializer<'de>, { - let opt: Option = Option::deserialize(deserializer)?; - Ok(opt.and_then(|v| serde_yml::from_value(v).ok())) + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.and_then(|v| serde_json::from_value(v).ok())) } /// Deserializes `Option`, returning `None` if the YAML @@ -375,8 +375,8 @@ fn deserialize_optional_cached_tailoring<'de, D>( where D: serde::Deserializer<'de>, { - let opt: Option = Option::deserialize(deserializer)?; - Ok(opt.and_then(|v| serde_yml::from_value(v).ok())) + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.and_then(|v| serde_json::from_value(v).ok())) } #[cfg(test)] @@ -424,8 +424,8 @@ mod tests { }), }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -433,14 +433,14 @@ mod tests { #[test] fn test_default_config_serializes_to_valid_yaml() { let config = Config::default(); - let yaml = serde_yml::to_string(&config).expect("serialize default to YAML"); - let _parsed: Config = serde_yml::from_str(&yaml).expect("deserialize default YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize default to YAML"); + let _parsed: Config = crate::yaml::from_str(&yaml).expect("deserialize default YAML"); } /// Empty YAML `{}` deserializes to default Config. #[test] fn test_empty_yaml_deserializes_to_default() { - let config: Config = serde_yml::from_str("{}").expect("deserialize empty YAML"); + let config: Config = crate::yaml::from_str("{}").expect("deserialize empty YAML"); assert_eq!(config, Config::default()); } @@ -462,8 +462,8 @@ mod tests { ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -471,7 +471,7 @@ mod tests { #[test] fn test_partial_yaml_deserializes_with_defaults() { let yaml = "model: opus\nbatch_size: 10\n"; - let config: Config = serde_yml::from_str(yaml).expect("deserialize partial YAML"); + let config: Config = crate::yaml::from_str(yaml).expect("deserialize partial YAML"); assert_eq!(config.model, Some("opus".to_string())); assert_eq!(config.batch_size, Some(10)); assert_eq!( @@ -490,12 +490,12 @@ mod tests { output_format: Some(OutputFormat::ClaudeMd), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!( yaml.contains("claude-md"), "YAML must contain 'claude-md': {yaml}" ); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -506,12 +506,12 @@ mod tests { output_format: Some(OutputFormat::AgentsMd), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!( yaml.contains("agents-md"), "YAML must contain 'agents-md': {yaml}" ); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -522,12 +522,12 @@ mod tests { output_format: Some(OutputFormat::CursorRules), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!( yaml.contains("cursor-rules"), "YAML must contain 'cursor-rules': {yaml}" ); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -535,7 +535,7 @@ mod tests { #[test] fn test_partial_yaml_with_output_format() { let yaml = "output_format: agents-md\n"; - let config: Config = serde_yml::from_str(yaml).expect("deserialize partial YAML"); + let config: Config = crate::yaml::from_str(yaml).expect("deserialize partial YAML"); assert_eq!(config.output_format, Some(OutputFormat::AgentsMd)); } @@ -546,12 +546,12 @@ mod tests { runner: Some("anthropic-api".to_string()), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!( yaml.contains("anthropic-api"), "YAML must contain 'anthropic-api': {yaml}" ); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -563,7 +563,7 @@ mod tests { openai_api_key: Some("sk-openai-test456".to_string()), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!( yaml.contains("sk-ant-test123"), "YAML must contain anthropic key: {yaml}" @@ -572,7 +572,7 @@ mod tests { yaml.contains("sk-openai-test456"), "YAML must contain openai key: {yaml}" ); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -580,7 +580,7 @@ mod tests { #[test] fn test_cursor_model_yaml_alias_loads_into_model() { let yaml = "cursor_model: cursor-fast\n"; - let config: Config = serde_yml::from_str(yaml).expect("deserialize cursor_model alias"); + let config: Config = crate::yaml::from_str(yaml).expect("deserialize cursor_model alias"); assert_eq!( config.model, Some("cursor-fast".to_string()), @@ -606,8 +606,8 @@ mod tests { ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -681,7 +681,7 @@ mod tests { #[test] fn test_corrupted_cached_analysis_yaml_deserializes_as_none() { let yaml = "cached_analysis:\n not_a_valid_field: oops\n"; - let cfg: Config = serde_yml::from_str(yaml).expect("config should still parse"); + let cfg: Config = crate::yaml::from_str(yaml).expect("config should still parse"); assert!( cfg.cached_analysis.is_none(), "corrupt cached_analysis must degrade to None, not fail config parsing" @@ -692,7 +692,7 @@ mod tests { fn test_corrupted_cached_tailoring_yaml_deserializes_as_none() { // Corrupt cached_tailoring → graceful degradation: treat as absent (None) let yaml = "cached_tailoring:\n not_a_valid_field: oops\n"; - let cfg: Config = serde_yml::from_str(yaml).expect("config should still parse"); + let cfg: Config = crate::yaml::from_str(yaml).expect("config should still parse"); assert!( cfg.cached_tailoring.is_none(), "corrupt cached_tailoring must degrade to None, not fail config parsing" @@ -706,9 +706,9 @@ mod tests { max_tokens: Some(32768), ..Config::default() }; - let yaml = serde_yml::to_string(&config).expect("serialize to YAML"); + let yaml = crate::yaml::to_string(&config).expect("serialize to YAML"); assert!(yaml.contains("32768"), "YAML must contain '32768': {yaml}"); - let deserialized: Config = serde_yml::from_str(&yaml).expect("deserialize from YAML"); + let deserialized: Config = crate::yaml::from_str(&yaml).expect("deserialize from YAML"); assert_eq!(config, deserialized); } @@ -716,7 +716,7 @@ mod tests { #[test] fn test_partial_yaml_with_max_tokens() { let yaml = "max_tokens: 8192\n"; - let config: Config = serde_yml::from_str(yaml).expect("deserialize partial YAML"); + let config: Config = crate::yaml::from_str(yaml).expect("deserialize partial YAML"); assert_eq!(config.max_tokens, Some(8192)); } @@ -726,4 +726,32 @@ mod tests { let config = Config::default(); assert_eq!(config.max_tokens, None); } + + /// A config file serde_yml 0.0.12 wrote, which every install before the + /// serde-saphyr swap has, reads back to the value serde_yml read it as. + #[test] + fn test_reads_config_written_by_serde_yml() { + let yaml = include_str!("../../tests/fixtures/serde_yml/config.yaml"); + let expected: serde_json::Value = + serde_json::from_str(include_str!("../../tests/fixtures/serde_yml/config.json")) + .expect("fixture JSON"); + let config: Config = crate::yaml::from_str(yaml).expect("parse serde_yml output"); + assert_eq!(serde_json::to_value(&config).expect("to JSON"), expected); + + let rewritten = crate::yaml::to_string(&config).expect("serialize to YAML"); + let reread: Config = crate::yaml::from_str(&rewritten).expect("reparse"); + assert_eq!(reread, config); + } + + /// A cache block that is valid YAML but fails its schema is dropped, + /// keeping the rest of the config. + #[test] + fn test_schema_mismatched_cache_blocks_are_dropped() { + let yaml = + "model: kept\ncached_analysis:\n repo_path: [not, a, string]\ncached_tailoring: 42\n"; + let config: Config = crate::yaml::from_str(yaml).expect("config should still parse"); + assert_eq!(config.model.as_deref(), Some("kept")); + assert!(config.cached_analysis.is_none()); + assert!(config.cached_tailoring.is_none()); + } } diff --git a/src/lib.rs b/src/lib.rs index 4a26d4cc8..1a2159bd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod tailoring; #[cfg(feature = "telemetry")] pub mod telemetry; pub mod update_check; +pub(crate) mod yaml; pub use cli::commands::handle_result; diff --git a/src/model_cache.rs b/src/model_cache.rs index 4df55a300..30f4690e6 100644 --- a/src/model_cache.rs +++ b/src/model_cache.rs @@ -88,7 +88,7 @@ fn cache_path() -> Option { fn load_cache_file(path: &std::path::Path) -> ModelCacheFile { std::fs::read_to_string(path) .ok() - .and_then(|s| serde_yml::from_str(&s).ok()) + .and_then(|s| crate::yaml::from_str(&s).ok()) .unwrap_or_default() } @@ -96,8 +96,8 @@ fn save_cache_file(path: &std::path::Path, cache: &ModelCacheFile) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // serde_yml::to_string on a well-formed struct is infallible in practice - let yaml = serde_yml::to_string(cache).unwrap_or_default(); + // YAML serialization of a well-formed struct is infallible in practice + let yaml = crate::yaml::to_string(cache).unwrap_or_default(); // 0600 and stage-then-rename (see `write_secure`'s own doc) -- model // names are not secrets, but this keeps the config directory's write // posture uniform and removes the write-then-chmod window a plain @@ -1635,4 +1635,27 @@ mod tests { ); assert!(result.is_empty()); } + + /// A model cache serde_yml 0.0.12 wrote reads back unchanged. + #[test] + fn test_reads_model_cache_written_by_serde_yml() { + let dir = tempdir().unwrap(); + let path = dir.path().join("models.yaml"); + std::fs::write( + &path, + include_str!("../tests/fixtures/serde_yml/model_cache.yaml"), + ) + .unwrap(); + let expected: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/serde_yml/model_cache.json")) + .unwrap(); + let cache = load_cache_file(&path); + assert_eq!(serde_json::to_value(&cache).unwrap(), expected); + + save_cache_file(&path, &cache); + assert_eq!( + serde_json::to_value(load_cache_file(&path)).unwrap(), + expected + ); + } } diff --git a/src/update_check.rs b/src/update_check.rs index 58a66b91b..aaa673f02 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -418,13 +418,13 @@ pub(crate) fn cache_path() -> Option { fn load_cache(path: &Path) -> UpdateCache { std::fs::read_to_string(path) .ok() - .and_then(|yaml| serde_yml::from_str(&yaml).ok()) + .and_then(|yaml| crate::yaml::from_str(&yaml).ok()) .unwrap_or_default() } /// Write the cache, and report whether it landed. fn save_cache(path: &Path, cache: &UpdateCache) -> bool { - let yaml = serde_yml::to_string(cache).unwrap_or_default(); + let yaml = crate::yaml::to_string(cache).unwrap_or_default(); path.parent() .is_some_and(|dir| std::fs::create_dir_all(dir).is_ok()) && crate::config::paths::write_secure(path, yaml.as_bytes()).is_ok() @@ -1404,4 +1404,22 @@ mod tests { Latest::Unavailable ); } + + /// An update-check cache serde_yml 0.0.12 wrote reads back unchanged. + #[test] + fn test_reads_update_cache_written_by_serde_yml() { + let yaml = include_str!("../tests/fixtures/serde_yml/update_cache.yaml"); + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../tests/fixtures/serde_yml/update_cache.json" + )) + .unwrap(); + let cache: UpdateCache = crate::yaml::from_str(yaml).expect("parse serde_yml output"); + assert_eq!(serde_json::to_value(&cache).unwrap(), expected); + + let rewritten = crate::yaml::to_string(&cache).unwrap(); + assert_eq!( + crate::yaml::from_str::(&rewritten).unwrap(), + cache + ); + } } diff --git a/src/yaml.rs b/src/yaml.rs new file mode 100644 index 000000000..b8aca9208 --- /dev/null +++ b/src/yaml.rs @@ -0,0 +1,119 @@ +//! YAML reading and writing, with the parser options kept in one place. +//! +//! The readers split on who wrote the input: [`from_str`] for the files the +//! CLI writes itself, [`from_repo_str`] for files in the repository under +//! analysis. + +use serde::de::DeserializeOwned; +use serde::Serialize; + +/// Parses YAML the CLI wrote itself: its config file, stored credentials and +/// caches. +/// +/// The parse budget is off because the config file carries the analysis and +/// tailoring caches, each allowed up to +/// [`CACHE_MAX_SIZE_BYTES`](crate::config::types::CACHE_MAX_SIZE_BYTES) +/// serialized, which can pass serde-saphyr's default node and event limits. +/// Snippets are off because these files hold tokens and API keys, and a +/// snippet quotes the lines around an error into its message. Non-finite +/// floats reach a `serde_json::Value` as null instead of failing the whole +/// document, so the config's lenient cache fields stay lenient. +pub(crate) fn from_str(input: &str) -> Result { + serde_saphyr::from_str_with_options( + input, + serde_saphyr::options! { + budget: None, + with_snippet: false, + non_finite_float_policy: serde_saphyr::NonFiniteFloatPolicy::PassThrough, + }, + ) +} + +/// Parses YAML read from the repository under analysis, such as +/// `pnpm-workspace.yaml`, under serde-saphyr's default parse budget. +pub(crate) fn from_repo_str(input: &str) -> Result { + serde_saphyr::from_str_with_options( + input, + serde_saphyr::options! { + with_snippet: false, + }, + ) +} + +/// Serializes a value as YAML, keeping every scalar on its key's line. +/// +/// Block scalars are off because `actual config show` redacts secrets line by +/// line, and a long API key written as a folded block would carry its value +/// onto the next line, past the redaction. +pub(crate) fn to_string(value: &T) -> Result { + serde_saphyr::to_string_with_options( + value, + serde_saphyr::ser_options! { + prefer_block_scalars: false, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// More nodes than serde-saphyr's default budget allows (250,000). + fn over_default_budget() -> String { + to_string(&vec![0u8; 300_000]).expect("serialize") + } + + #[test] + fn test_own_files_parse_past_the_default_budget() { + let parsed: Vec = from_str(&over_default_budget()).expect("no budget on own files"); + assert_eq!(parsed.len(), 300_000); + } + + #[test] + fn test_repo_files_keep_the_default_budget() { + let err = from_repo_str::>(&over_default_budget()).unwrap_err(); + assert!( + err.to_string().contains("budget"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_errors_do_not_quote_neighbouring_lines() { + let yaml = "access_token: secret-access-value\norganization_id: [1, 2]\n"; + let own = from_str::(yaml).unwrap_err(); + let repo = from_repo_str::(yaml).unwrap_err(); + for err in [own.to_string(), repo.to_string()] { + assert!( + !err.contains("secret-access-value"), + "error quotes a secret: {err}" + ); + } + } + + #[test] + fn test_non_finite_floats_reach_json_values_as_null() { + let value: serde_json::Value = from_str("a: .nan\nb: .inf\nc: 1.5\n").expect("parse"); + assert_eq!(value, serde_json::json!({ "a": null, "b": null, "c": 1.5 })); + } + + #[test] + fn test_scalars_stay_on_one_line() { + let long = "x".repeat(300); + let value = + std::collections::BTreeMap::from([("long", long.as_str()), ("multi", "a\nb\n")]); + let yaml = to_string(&value).expect("serialize"); + assert_eq!(yaml.lines().count(), 2, "got:\n{yaml}"); + let back: std::collections::BTreeMap = from_str(&yaml).expect("parse"); + assert_eq!(back["long"], long); + assert_eq!(back["multi"], "a\nb\n"); + } + + #[test] + fn test_round_trip() { + let value = std::collections::BTreeMap::from([("key", vec!["a", "b: c"])]); + let yaml = to_string(&value).expect("serialize"); + let back: std::collections::BTreeMap> = from_str(&yaml).expect("parse"); + assert_eq!(back["key"], ["a", "b: c"]); + } +} diff --git a/tests/fixtures/serde_yml/config.json b/tests/fixtures/serde_yml/config.json new file mode 100644 index 000000000..f959ff767 --- /dev/null +++ b/tests/fixtures/serde_yml/config.json @@ -0,0 +1,173 @@ +{ + "api_url": "https://api.example.test", + "model": "claude-sonnet-4", + "include_categories": [ + "security", + "testing" + ], + "exclude_categories": [], + "include_general": true, + "max_per_framework": 5, + "max_budget_usd": 0.1, + "batch_size": 10, + "concurrency": 4, + "invocation_timeout_secs": 4294967296, + "output_format": "agents-md", + "telemetry": { + "enabled": false + }, + "rejected_adrs": { + "key: with colon": [], + "/home/user/project": [ + "123", + "yes", + "null", + "~", + " padded ", + "a # not a comment", + "key: value", + "café ✓", + "", + "- dash", + "*star", + "&", + "!bang", + "%pct", + "@at", + "`tick", + "0o17", + "1e3", + ".inf", + "true", + "2026-10-25", + "multi\nline\n", + "tab\there", + "quote ' and \" both", + "{brace}", + "[bracket]", + "trailing:", + "?question", + "|pipe", + ">gt", + "'single'", + "\"double\"" + ] + }, + "sticky_repo_scope": { + "/home/user/project": { + "repo_unique_id": "github.com/acme/project", + "qualified_name": "acme/project" + }, + "/home/user/other": {} + }, + "cached_analysis": { + "repo_path": "/home/user/project", + "head_commit": "abc123", + "config_hash": "deadbeef", + "analysis": { + "is_monorepo": true, + "workspace_type": "pnpm", + "projects": [ + { + "path": "apps/web", + "name": "web", + "languages": [ + { + "language": "typescript", + "loc": 1200 + }, + { + "language": "haskell", + "loc": 3 + } + ], + "frameworks": [ + { + "name": "nextjs", + "category": "web-frontend", + "source": "package_json" + }, + { + "name": "custom-thing", + "category": "something-else" + } + ], + "package_manager": "pnpm", + "description": "The web app: front end", + "dep_count": 12, + "dev_dep_count": 4, + "selection": { + "language": { + "language": "typescript", + "loc": 1200 + }, + "framework": { + "name": "nextjs", + "category": "web-frontend" + }, + "auto_selected": true + } + }, + { + "path": ".", + "name": "root", + "languages": [], + "frameworks": [], + "package_manager": null, + "description": null, + "dep_count": 0, + "dev_dep_count": 0 + } + ] + }, + "analyzed_at": "2026-10-25T10:00:00.123456789Z" + }, + "cached_tailoring": { + "cache_key": "abc123def456", + "repo_path": "/home/user/project", + "tailoring": { + "files": [ + { + "path": "CLAUDE.md", + "sections": [ + { + "adr_id": "adr-1", + "content": "## Rule one\nUse `cargo fmt`.\n\n- item: with colon\n indented line\ntrailing spaces \n" + }, + { + "adr_id": "adr-2", + "content": "single line" + } + ], + "reasoning": "Applies because: tests" + } + ], + "skipped_adrs": [ + { + "id": "adr-9", + "reason": "not relevant" + } + ], + "summary": { + "total_input": 3, + "applicable": 2, + "not_applicable": 1, + "files_generated": 1 + } + }, + "tailored_at": "2026-10-25T11:00:00Z" + }, + "runner": "anthropic-api", + "anthropic_api_key": "sk-test-anthropic", + "openai_api_key": "sk-test-openai", + "cursor_api_key": "cur-test", + "max_turns": 8, + "max_tokens": 4096, + "rules_min_score": 0.4, + "rules_min_score_by_repo": { + "/home/user/project": 0.55 + }, + "update_check": { + "enabled": true + } +} diff --git a/tests/fixtures/serde_yml/config.yaml b/tests/fixtures/serde_yml/config.yaml new file mode 100644 index 000000000..ef3d333a6 --- /dev/null +++ b/tests/fixtures/serde_yml/config.yaml @@ -0,0 +1,131 @@ +api_url: https://api.example.test +model: claude-sonnet-4 +include_categories: +- security +- testing +exclude_categories: [] +include_general: true +max_per_framework: 5 +max_budget_usd: 0.1 +batch_size: 10 +concurrency: 4 +invocation_timeout_secs: 4294967296 +output_format: agents-md +telemetry: + enabled: false +rejected_adrs: + 'key: with colon': [] + /home/user/project: + - '123' + - 'yes' + - 'null' + - '~' + - ' padded ' + - 'a # not a comment' + - 'key: value' + - café ✓ + - '' + - '- dash' + - '*star' + - '&' + - '!bang' + - '%pct' + - '@at' + - '`tick' + - '0o17' + - '1e3' + - '.inf' + - 'true' + - '2026-10-25' + - | + multi + line + - "tab\there" + - quote ' and " both + - '{brace}' + - '[bracket]' + - 'trailing:' + - ?question + - '|pipe' + - '>gt' + - '''single''' + - '"double"' +sticky_repo_scope: + /home/user/project: + repo_unique_id: github.com/acme/project + qualified_name: acme/project + /home/user/other: {} +cached_analysis: + repo_path: /home/user/project + head_commit: abc123 + config_hash: deadbeef + analysis: + is_monorepo: true + workspace_type: pnpm + projects: + - path: apps/web + name: web + languages: + - language: typescript + loc: 1200 + - language: haskell + loc: 3 + frameworks: + - name: nextjs + category: web-frontend + source: package_json + - name: custom-thing + category: something-else + package_manager: pnpm + description: 'The web app: front end' + dep_count: 12 + dev_dep_count: 4 + selection: + language: + language: typescript + loc: 1200 + framework: + name: nextjs + category: web-frontend + auto_selected: true + - path: '.' + name: root + languages: [] + frameworks: [] + package_manager: null + description: null + dep_count: 0 + dev_dep_count: 0 + analyzed_at: '2026-10-25T10:00:00.123456789Z' +cached_tailoring: + cache_key: abc123def456 + repo_path: /home/user/project + tailoring: + files: + - path: CLAUDE.md + sections: + - adr_id: adr-1 + content: "## Rule one\nUse `cargo fmt`.\n\n- item: with colon\n indented line\ntrailing spaces \n" + - adr_id: adr-2 + content: single line + reasoning: 'Applies because: tests' + skipped_adrs: + - id: adr-9 + reason: not relevant + summary: + total_input: 3 + applicable: 2 + not_applicable: 1 + files_generated: 1 + tailored_at: '2026-10-25T11:00:00Z' +runner: anthropic-api +anthropic_api_key: sk-test-anthropic +openai_api_key: sk-test-openai +cursor_api_key: cur-test +max_turns: 8 +max_tokens: 4096 +rules_min_score: 0.4 +rules_min_score_by_repo: + /home/user/project: 0.55 +update_check: + enabled: true diff --git a/tests/fixtures/serde_yml/credentials.json b/tests/fixtures/serde_yml/credentials.json new file mode 100644 index 000000000..7b78e4b52 --- /dev/null +++ b/tests/fixtures/serde_yml/credentials.json @@ -0,0 +1,12 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln", + "refresh_token": "rt-123:abc/def+ghi=", + "token_type": "Bearer", + "expires_at": "2026-10-25T12:34:56Z", + "scope": "openid profile offline_access", + "organization_id": "0042", + "member_id": "7", + "email": "dev+test@example.com", + "subject": "auth0|abc123", + "auth_url": "https://auth.example.test" +} diff --git a/tests/fixtures/serde_yml/credentials.yaml b/tests/fixtures/serde_yml/credentials.yaml new file mode 100644 index 000000000..62b2ed492 --- /dev/null +++ b/tests/fixtures/serde_yml/credentials.yaml @@ -0,0 +1,10 @@ +access_token: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln +refresh_token: rt-123:abc/def+ghi= +token_type: Bearer +expires_at: '2026-10-25T12:34:56Z' +scope: openid profile offline_access +organization_id: '0042' +member_id: '7' +email: dev+test@example.com +subject: auth0|abc123 +auth_url: https://auth.example.test diff --git a/tests/fixtures/serde_yml/model_cache.json b/tests/fixtures/serde_yml/model_cache.json new file mode 100644 index 000000000..756e616a7 --- /dev/null +++ b/tests/fixtures/serde_yml/model_cache.json @@ -0,0 +1,14 @@ +{ + "openai": { + "fetched_at": "2026-10-25T09:00:00Z", + "models": [ + "gpt-5", + "o3", + "4o" + ] + }, + "anthropic": { + "fetched_at": null, + "models": [] + } +} diff --git a/tests/fixtures/serde_yml/model_cache.yaml b/tests/fixtures/serde_yml/model_cache.yaml new file mode 100644 index 000000000..654e4e5ea --- /dev/null +++ b/tests/fixtures/serde_yml/model_cache.yaml @@ -0,0 +1,9 @@ +openai: + fetched_at: '2026-10-25T09:00:00Z' + models: + - gpt-5 + - o3 + - '4o' +anthropic: + fetched_at: null + models: [] diff --git a/tests/fixtures/serde_yml/update_cache.json b/tests/fixtures/serde_yml/update_cache.json new file mode 100644 index 000000000..91b393394 --- /dev/null +++ b/tests/fixtures/serde_yml/update_cache.json @@ -0,0 +1,5 @@ +{ + "checked_at": "2026-10-25T08:00:00Z", + "latest_version": "0.10.0", + "notified_at": "2026-10-24T08:00:00.500Z" +} diff --git a/tests/fixtures/serde_yml/update_cache.yaml b/tests/fixtures/serde_yml/update_cache.yaml new file mode 100644 index 000000000..fb4717b12 --- /dev/null +++ b/tests/fixtures/serde_yml/update_cache.yaml @@ -0,0 +1,3 @@ +checked_at: '2026-10-25T08:00:00Z' +latest_version: '0.10.0' +notified_at: '2026-10-24T08:00:00.500Z'