返回 CodeWhale
config_bundles.rs
根目录 / crates / cli / src / config_bundles.rs
1 //! Portable config bundles: `codewhale config import` / `config export --portable`.
2 //!
3 //! A bundle is a TOML or JSON document carrying a portable subset of a
4 //! CodeWhale configuration (preferences, harness profiles, provider
5 //! non-secret settings, project/global sections) between machines. The
6 //! envelope is versioned and strict (`deny_unknown_fields`), secrets are
7 //! rejected by key name and value shape (never echoed), parsing is bounded,
8 //! and application is transactional with a timestamped backup and rollback.
9 //!
10 //! Security contract:
11 //! - No secret ever round-trips: fields whose key matches
12 //! [`codewhale_config::is_sensitive_config_key`] are rejected on import and
13 //! dropped on export, and bare credential-shaped values are rejected by
14 //! value shape. Rejection messages name the field, never the value.
15 //! - Input size is capped (5 MiB, matching the skill installer's cap).
16 //! - HTTPS only for remote fetch, except plain `http` on loopback; redirects
17 //! are followed at most a bounded number of times within the same scheme.
18 //! - Bundle-declared file paths must resolve inside the target config
19 //! directory; traversal and symlink escapes are refused.
20 //! - Project scope never mutates the user-global document and vice versa.
21
22 use std::io::{IsTerminal, Read};
23 use std::path::{Path, PathBuf};
24
25 use anyhow::{Context, Result, anyhow, bail};
26 use serde::{Deserialize, Serialize};
27
28 use codewhale_config::{ConfigToml, is_sensitive_config_key};
29
30 /// Maximum accepted bundle size, both for reads and remote fetches.
31 /// Matches the skill installer's 5 MiB cap.
32 pub const MAX_BUNDLE_BYTES: u64 = 5 * 1024 * 1024;
33
34 /// Envelope `kind` value required by every bundle.
35 pub const BUNDLE_KIND: &str = "codewhale.portable-config";
36
37 /// Envelope `schema_version` accepted by this build.
38 pub const BUNDLE_SCHEMA_VERSION: u64 = 1;
39
40 /// Maximum number of HTTP redirects followed during a remote fetch.
41 const MAX_REDIRECTS: usize = 5;
42
43 /// Timeout for the remote fetch, in seconds.
44 const FETCH_TIMEOUT_SECS: u64 = 30;
45
46 /// Credential-shaped value prefixes rejected even under a benign key name.
47 /// Conservative on purpose: only well-known provider token shapes.
48 const SECRET_VALUE_PREFIXES: [&str; 6] = ["sk-", "Bearer ", "ghp_", "xoxb-", "AKIA", "eyJ"];
49
50 // ---------------------------------------------------------------------------
51 // Envelope
52 // ---------------------------------------------------------------------------
53
54 /// Strict portable-bundle envelope. Unknown fields fail the parse: a bundle
55 /// written by a newer schema must not be silently half-applied.
56 #[derive(Debug, Clone, Deserialize, Serialize)]
57 #[serde(deny_unknown_fields)]
58 pub struct PortableBundle {
59 pub schema_version: u64,
60 pub kind: String,
61 #[serde(default)]
62 pub metadata: BundleMetadata,
63 #[serde(default)]
64 pub preferences: BundleTable,
65 #[serde(default)]
66 pub profiles: BundleTable,
67 #[serde(default)]
68 pub plugins: BundleTable,
69 #[serde(default)]
70 pub project: BundleTable,
71 #[serde(default)]
72 pub global: BundleTable,
73 }
74
75 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
76 #[serde(deny_unknown_fields)]
77 pub struct BundleMetadata {
78 #[serde(default)]
79 pub name: Option<String>,
80 #[serde(default)]
81 pub created_at: Option<String>,
82 #[serde(default)]
83 pub generator: Option<String>,
84 }
85
86 /// One bundle section: a flat table of config keys to values. Keys inside a
87 /// section are data, not schema, so unknown keys parse here — credential
88 /// rejection happens at plan time by name and value shape.
89 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
90 pub struct BundleTable {
91 #[serde(flatten)]
92 pub entries: std::collections::BTreeMap<String, toml::Value>,
93 }
94
95 // ---------------------------------------------------------------------------
96 // Parsing (bounded)
97 // ---------------------------------------------------------------------------
98
99 /// Parse a bundle from raw bytes, rejecting oversize input before parse.
100 pub fn parse_bundle_bytes(raw: &[u8], source: &str) -> Result<PortableBundle> {
101 if raw.len() as u64 > MAX_BUNDLE_BYTES {
102 bail!(
103 "bundle at {source} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
104 raw.len()
105 );
106 }
107 let text = std::str::from_utf8(raw)
108 .with_context(|| format!("bundle at {source} is not valid UTF-8"))?;
109 parse_bundle_str(text, source)
110 }
111
112 /// Parse a bundle document: TOML by default, JSON when the source ends in
113 /// `.json` or the document starts with `{`.
114 pub fn parse_bundle_str(text: &str, source: &str) -> Result<PortableBundle> {
115 let trimmed = text.trim_start();
116 let bundle = if trimmed.starts_with('{') || source.ends_with(".json") {
117 // serde_json keeps the last of two identical object keys. A bundle is
118 // a reviewed plan, so a repeated key must fail before anything is
119 // planned or written, exactly as TOML already refuses duplicates.
120 reject_duplicate_json_keys(text)
121 .with_context(|| format!("bundle at {source} is not valid JSON"))?;
122 serde_json::from_str::<PortableBundle>(text)
123 .with_context(|| format!("bundle at {source} is not valid JSON"))?
124 } else {
125 toml::from_str::<PortableBundle>(text)
126 .with_context(|| format!("bundle at {source} is not valid TOML"))?
127 };
128 validate_bundle(&bundle, source)?;
129 Ok(bundle)
130 }
131
132 /// Fail closed on a JSON document that repeats an object key at any depth.
133 ///
134 /// The document is walked with a deserializer seed that never materializes
135 /// values, so the check costs one pass and reports the first offending key
136 /// path without echoing any value.
137 fn reject_duplicate_json_keys(text: &str) -> Result<()> {
138 use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
139 use std::fmt;
140
141 struct NoDuplicates<'a> {
142 path: &'a mut Vec<String>,
143 }
144
145 impl<'de> DeserializeSeed<'de> for NoDuplicates<'_> {
146 type Value = ();
147
148 fn deserialize<D>(self, deserializer: D) -> Result<(), D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 deserializer.deserialize_any(self)
153 }
154 }
155
156 impl<'de> Visitor<'de> for NoDuplicates<'_> {
157 type Value = ();
158
159 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str("a JSON value without duplicate object keys")
161 }
162
163 fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<(), E> {
164 Ok(())
165 }
166 fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<(), E> {
167 Ok(())
168 }
169 fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<(), E> {
170 Ok(())
171 }
172 fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<(), E> {
173 Ok(())
174 }
175 fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<(), E> {
176 Ok(())
177 }
178 fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
179 Ok(())
180 }
181 fn visit_none<E: serde::de::Error>(self) -> Result<(), E> {
182 Ok(())
183 }
184
185 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
186 let mut index = 0usize;
187 loop {
188 self.path.push(format!("[{index}]"));
189 let next = seq.next_element_seed(NoDuplicates { path: self.path });
190 self.path.pop();
191 if next?.is_none() {
192 return Ok(());
193 }
194 index += 1;
195 }
196 }
197
198 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
199 let mut seen = std::collections::BTreeSet::new();
200 while let Some(key) = map.next_key::<String>()? {
201 if !seen.insert(key.clone()) {
202 let mut path = self.path.clone();
203 path.push(key);
204 return Err(A::Error::custom(format!(
205 "duplicate key {:?}",
206 path.join(".")
207 )));
208 }
209 self.path.push(key);
210 let nested = map.next_value_seed(NoDuplicates { path: self.path });
211 self.path.pop();
212 nested?;
213 }
214 Ok(())
215 }
216 }
217
218 let mut deserializer = serde_json::Deserializer::from_str(text);
219 let mut path = Vec::new();
220 NoDuplicates { path: &mut path }
221 .deserialize(&mut deserializer)
222 .map_err(|error| anyhow::anyhow!("{error}"))?;
223 deserializer
224 .end()
225 .map_err(|error| anyhow::anyhow!("{error}"))?;
226 Ok(())
227 }
228
229 fn validate_bundle(bundle: &PortableBundle, source: &str) -> Result<()> {
230 if bundle.kind != BUNDLE_KIND {
231 bail!(
232 "bundle at {source} has kind {:?}; expected {BUNDLE_KIND:?}",
233 bundle.kind
234 );
235 }
236 if bundle.schema_version != BUNDLE_SCHEMA_VERSION {
237 bail!(
238 "bundle at {source} has schema_version {}; this build understands {BUNDLE_SCHEMA_VERSION}",
239 bundle.schema_version
240 );
241 }
242 Ok(())
243 }
244
245 // ---------------------------------------------------------------------------
246 // Secret rejection
247 // ---------------------------------------------------------------------------
248
249 /// One rejected entry: the dotted key path and the reason. Values are never
250 /// included — the reason and path are all a reviewer needs.
251 #[derive(Debug, Clone, PartialEq, Eq)]
252 pub struct RejectedEntry {
253 pub key: String,
254 pub reason: String,
255 }
256
257 /// Scan every section of the bundle for non-portable entries. Import and
258 /// export use the same path predicate, so machine-local route/execution/trust
259 /// authority cannot be stripped in one direction but accepted in the other.
260 /// String leaves are additionally rejected by credential shape.
261 pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec<RejectedEntry> {
262 let mut rejected = Vec::new();
263 for (section, table) in [
264 ("preferences", &bundle.preferences),
265 ("profiles", &bundle.profiles),
266 ("plugins", &bundle.plugins),
267 ("project", &bundle.project),
268 ("global", &bundle.global),
269 ] {
270 for (key, value) in &table.entries {
271 let dotted = format!("{section}.{key}");
272 if let Some(reason) = nonportable_path_reason(key) {
273 rejected.push(RejectedEntry {
274 key: dotted,
275 reason: reason.to_string(),
276 });
277 continue;
278 }
279 if let Some(reason) = value_rejection_reason(key, value) {
280 rejected.push(RejectedEntry {
281 key: dotted,
282 reason,
283 });
284 }
285 }
286 }
287 rejected
288 }
289
290 /// Maximum nesting depth the export walkers descend. Config files are
291 /// shallow; anything deeper is pathological and fails closed.
292 const MAX_EXPORT_WALK_DEPTH: usize = 64;
293
294 /// Why a value carries nested non-portable authority or looks like a bare
295 /// credential, or `None` when it is safe to move between machines.
296 fn value_rejection_reason(path: &str, value: &toml::Value) -> Option<String> {
297 value_rejection_reason_at(path, value, 0)
298 }
299
300 fn value_rejection_reason_at(path: &str, value: &toml::Value, depth: usize) -> Option<String> {
301 if depth > MAX_EXPORT_WALK_DEPTH {
302 return Some(format!(
303 "nested more than {MAX_EXPORT_WALK_DEPTH} levels deep"
304 ));
305 }
306 if let Some(reason) = nonportable_value_reason(path, value) {
307 return Some(reason.to_string());
308 }
309 match value {
310 toml::Value::String(text) => string_secret_reason(text),
311 toml::Value::Array(items) => items
312 .iter()
313 .find_map(|value| value_rejection_reason_at(path, value, depth + 1))
314 .map(|reason| format!("array contains an entry where {reason}")),
315 toml::Value::Table(map) => {
316 for (key, nested_value) in map {
317 let child_path = if path.is_empty() {
318 key.clone()
319 } else {
320 format!("{path}.{key}")
321 };
322 if let Some(reason) = nonportable_path_reason(&child_path) {
323 return Some(format!("nested key {key:?} {reason}"));
324 }
325 if let Some(reason) =
326 value_rejection_reason_at(&child_path, nested_value, depth + 1)
327 {
328 return Some(format!("nested under {key:?}, {reason}"));
329 }
330 }
331 None
332 }
333 _ => None,
334 }
335 }
336
337 fn string_secret_reason(text: &str) -> Option<String> {
338 if let Some(prefix) = SECRET_VALUE_PREFIXES
339 .iter()
340 .find(|prefix| text.trim().starts_with(*prefix))
341 {
342 return Some(format!(
343 "value has the shape of a credential (prefix {prefix:?} redacted)"
344 ));
345 }
346 if text.contains(codewhale_config::persistence::REDACTED) {
347 // A placeholder is the residue of redaction, never a real setting;
348 // exporting it would carry nothing and importing it would write the
349 // placeholder into the live document.
350 return Some("value contains a redaction placeholder".to_string());
351 }
352 if codewhale_config::persistence::redact_secrets(text) != text {
353 return Some("value contains credential-shaped text".to_string());
354 }
355 None
356 }
357
358 fn is_sensitive_bundle_key(key: &str) -> bool {
359 if is_sensitive_config_key(key) || is_credential_authority_key(key) {
360 return true;
361 }
362 // Normalize the complete dotted path, not only its final component. A
363 // quoted TOML key such as `"api.key"` reaches us without its quotes and
364 // is otherwise indistinguishable from two structural components. Either
365 // representation names credential material and must fail closed.
366 let normalized = normalize_bundle_key(key);
367
368 matches!(
369 normalized.as_str(),
370 "access_key"
371 | "access_token"
372 | "api_key"
373 | "api_keys"
374 | "apikey"
375 | "authorization"
376 | "bearer"
377 | "client_secret"
378 | "cookie"
379 | "credential"
380 | "credentials"
381 | "id_token"
382 | "password"
383 | "passwords"
384 | "passwd"
385 | "private_key"
386 | "proxy_authorization"
387 | "refresh_token"
388 | "secret"
389 | "secrets"
390 | "set_cookie"
391 | "token"
392 | "tokens"
393 ) || normalized.ends_with("_access_key")
394 || normalized.ends_with("_api_key")
395 || normalized.ends_with("_authorization")
396 || normalized.ends_with("_cookie")
397 || normalized.ends_with("_password")
398 || normalized.ends_with("_private_key")
399 || normalized.ends_with("_secret")
400 || normalized.ends_with("_token")
401 }
402
403 fn normalize_bundle_key(key: &str) -> String {
404 let segment = key.trim().trim_matches('"');
405 let chars: Vec<char> = segment.chars().collect();
406 let mut normalized = String::with_capacity(segment.len());
407 for (index, character) in chars.iter().copied().enumerate() {
408 if !character.is_ascii_alphanumeric() {
409 if !normalized.ends_with('_') {
410 normalized.push('_');
411 }
412 continue;
413 }
414 if character.is_ascii_uppercase() {
415 let previous = index.checked_sub(1).and_then(|index| chars.get(index));
416 let next = chars.get(index + 1);
417 let starts_word = previous.is_some_and(|character| {
418 character.is_ascii_lowercase() || character.is_ascii_digit()
419 }) || (previous
420 .is_some_and(|character| character.is_ascii_uppercase())
421 && next.is_some_and(|character| character.is_ascii_lowercase()));
422 if starts_word && !normalized.ends_with('_') {
423 normalized.push('_');
424 }
425 normalized.push(character.to_ascii_lowercase());
426 } else {
427 normalized.push(character.to_ascii_lowercase());
428 }
429 }
430 normalized.trim_matches('_').to_string()
431 }
432
433 fn is_credential_authority_key(key: &str) -> bool {
434 let normalized = normalize_bundle_key(key);
435 if normalized == "external_credentials"
436 || normalized.ends_with("_external_credentials")
437 || normalized == "oauth_credential_generation"
438 || normalized.ends_with("_oauth_credential_generation")
439 {
440 return true;
441 }
442 // `auth_mode` is a declarative protocol selection; an `auth` table is
443 // executable or secret-store authority and is intentionally non-portable.
444 key.split('.')
445 .map(normalize_bundle_key)
446 .any(|segment| segment == "auth")
447 }
448
449 fn is_machine_bound_top_level_key(key: &str) -> bool {
450 key.split('.')
451 .next()
452 .map(normalize_bundle_key)
453 .is_some_and(|root| {
454 matches!(
455 root.as_str(),
456 "auto_review"
457 | "hooks"
458 | "instructions"
459 | "managed_config_path"
460 | "project_instruction_imports"
461 | "projects"
462 | "requirements_path"
463 | "route_preferences_version"
464 | "route_preferences_migration"
465 | "runtime_api"
466 | "workspace"
467 )
468 })
469 }
470
471 fn is_nonportable_lsp_authority_key(key: &str) -> bool {
472 let mut segments = key.split('.').map(normalize_bundle_key);
473 matches!(segments.next().as_deref(), Some("lsp"))
474 && matches!(segments.next().as_deref(), Some("custom" | "servers"))
475 }
476
477 fn is_nonportable_nested_authority_key(key: &str) -> bool {
478 let segments = key.split('.').map(normalize_bundle_key).collect::<Vec<_>>();
479 match segments.as_slice() {
480 [root, field, ..]
481 if root == "tools" && matches!(field.as_str(), "overrides" | "plugin_dir") =>
482 {
483 true
484 }
485 [root, field, ..] if root == "update" && field == "update_uri" => true,
486 [root, field, ..] if root == "notifications" && field == "sound_file" => true,
487 [root, field, ..] if root == "speech" && field == "output_dir" => true,
488 [root, .., field] if root == "providers" && field == "api_key_env" => true,
489 _ => false,
490 }
491 }
492
493 fn is_machine_specific_config_path(path: &str) -> bool {
494 let path = normalize_bundle_key(path);
495 MACHINE_SPECIFIC_KEYS.iter().any(|key| {
496 let key = normalize_bundle_key(key);
497 path == key
498 || path
499 .strip_suffix(&key)
500 .is_some_and(|prefix| prefix.ends_with('_'))
501 })
502 }
503
504 fn nonportable_path_reason(path: &str) -> Option<&'static str> {
505 if is_machine_bound_top_level_key(path) {
506 return Some("carries machine-bound execution or trust authority");
507 }
508 if is_nonportable_lsp_authority_key(path) {
509 return Some("carries executable LSP authority");
510 }
511 if is_nonportable_nested_authority_key(path) {
512 return Some("carries machine-local route or execution authority");
513 }
514 if is_machine_specific_config_path(path) {
515 return Some("carries machine-local route or filesystem authority");
516 }
517 if is_credential_authority_key(path) {
518 return Some("carries machine-local credential authority");
519 }
520 if is_sensitive_bundle_key(path) {
521 return Some("names credential material");
522 }
523 None
524 }
525
526 /// Telemetry opt-out is safe to move between machines, but opt-in is durable
527 /// user consent coupled to SetupState. A portable bundle may tighten that
528 /// consent (`false`); it must never manufacture or transfer `true`.
529 fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> {
530 let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry";
531 (top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
532 .then_some("would port telemetry opt-in consent between machines")
533 }
534
535 // ---------------------------------------------------------------------------
536 // Import plan
537 // ---------------------------------------------------------------------------
538
539 /// What applying the bundle would do, computed before anything is written.
540 #[derive(Debug, Clone, Default, PartialEq, Eq)]
541 pub struct ImportPlan {
542 pub added: Vec<String>,
543 pub changed: Vec<String>,
544 pub skipped: Vec<String>,
545 pub conflicting: Vec<String>,
546 pub rejected: Vec<RejectedEntry>,
547 }
548
549 impl ImportPlan {
550 #[must_use]
551 pub fn is_no_op(&self) -> bool {
552 self.added.is_empty() && self.changed.is_empty()
553 }
554 }
555
556 /// Compute the deterministic import plan for `bundle` against `config`.
557 ///
558 /// `section` selects the target document mapping: bundle `project` entries
559 /// apply only to a project-scope document, `global` entries only to a
560 /// user-global one; `preferences`, `profiles`, and `plugins` apply to both.
561 /// Entries that would not touch the target document are `skipped`, so the
562 /// same bundle imports cleanly at either scope.
563 pub fn plan_import(bundle: &PortableBundle, config: &ConfigToml, scope: BundleScope) -> ImportPlan {
564 let mut plan = ImportPlan {
565 rejected: find_rejected_entries(bundle),
566 ..ImportPlan::default()
567 };
568 let rejected_keys: std::collections::BTreeSet<&str> = plan
569 .rejected
570 .iter()
571 .map(|entry| entry.key.as_str())
572 .collect();
573 // Sections are presentation and scope labels over one flat ConfigToml
574 // keyspace. Two applicable sections naming the same key would otherwise
575 // make apply order decide which value wins. Detect that ambiguity before
576 // classifying or writing any entry.
577 let mut applicable_key_counts = std::collections::BTreeMap::<&str, usize>::new();
578 for (section, table) in [
579 ("preferences", &bundle.preferences),
580 ("profiles", &bundle.profiles),
581 ("plugins", &bundle.plugins),
582 ("project", &bundle.project),
583 ("global", &bundle.global),
584 ] {
585 if section_applies(section, scope) {
586 for key in table.entries.keys() {
587 *applicable_key_counts.entry(key.as_str()).or_default() += 1;
588 }
589 }
590 }
591 let colliding_keys: std::collections::BTreeSet<&str> = applicable_key_counts
592 .into_iter()
593 .filter_map(|(key, count)| (count > 1).then_some(key))
594 .collect();
595
596 for (section, table) in [
597 ("preferences", &bundle.preferences),
598 ("profiles", &bundle.profiles),
599 ("plugins", &bundle.plugins),
600 ("project", &bundle.project),
601 ("global", &bundle.global),
602 ] {
603 let dotted = |key: &str| format!("{section}.{key}");
604 let applies = section_applies(section, scope);
605 for (key, value) in &table.entries {
606 let dotted = dotted(key);
607 if rejected_keys.contains(dotted.as_str())
608 || (applies && colliding_keys.contains(key.as_str()))
609 {
610 plan.conflicting.push(dotted);
611 continue;
612 }
613 if !applies {
614 plan.skipped.push(dotted);
615 continue;
616 }
617 if config_value_matches(config, key, value) {
618 plan.skipped.push(dotted);
619 } else if config_has_value(config, key) {
620 plan.changed.push(dotted);
621 } else {
622 plan.added.push(dotted);
623 }
624 }
625 }
626 plan
627 }
628
629 fn config_value_matches(config: &ConfigToml, key: &str, value: &toml::Value) -> bool {
630 let semantically_equal = (|| {
631 let current = config_document(config).ok()?;
632 let mut candidate = config.clone();
633 apply_config_value(&mut candidate, key, value).ok()?;
634 Some(config_document(&candidate).ok()? == current)
635 })()
636 .unwrap_or(false);
637 semantically_equal
638 || config.get_value(key).is_some_and(|current| {
639 render_toml_value(value).ok().as_deref() == Some(current.as_str())
640 })
641 }
642
643 fn config_has_value(config: &ConfigToml, key: &str) -> bool {
644 config_document(config)
645 .ok()
646 .is_some_and(|table| table.contains_key(key))
647 || config.get_value(key).is_some()
648 }
649
650 fn section_applies(section: &str, scope: BundleScope) -> bool {
651 match section {
652 "project" => scope == BundleScope::Project,
653 "global" => scope == BundleScope::Global,
654 _ => true,
655 }
656 }
657
658 // ---------------------------------------------------------------------------
659 // Scope
660 // ---------------------------------------------------------------------------
661
662 /// Which document an import/export targets.
663 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
664 pub enum BundleScope {
665 /// The user-global config (`~/.codewhale/config.toml` by default).
666 Global,
667 /// The workspace-scoped config (`<repo>/.codewhale/config.toml`).
668 Project,
669 }
670
671 impl BundleScope {
672 #[must_use]
673 pub fn label(self) -> &'static str {
674 match self {
675 Self::Global => "global",
676 Self::Project => "project",
677 }
678 }
679 }
680
681 fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> {
682 let workspace_scoped = codewhale_config::config_path_is_workspace_scoped(target);
683 match (scope, workspace_scoped) {
684 (BundleScope::Project, false) => bail!(
685 "--project requires a workspace config ({} is the user-global document)",
686 target.display()
687 ),
688 (BundleScope::Global, true) => bail!(
689 "global bundle operations cannot target workspace config {}; rerun with --project or select the user-global config",
690 target.display()
691 ),
692 _ => {}
693 }
694 Ok(())
695 }
696
697 // ---------------------------------------------------------------------------
698 // Path safety
699 // ---------------------------------------------------------------------------
700
701 /// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
702 /// escapes. Returns the resolved path or an error naming the refusal — the
703 /// candidate string itself is safe to echo (it is config data, not a secret).
704 /// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
705 /// escapes. Returns the joined path or an error naming the refusal.
706 /// Reserved for path-carrying bundle sections (none shipped yet); exercised
707 /// by the traversal tests so the contract cannot silently rot.
708 #[cfg_attr(
709 not(test),
710 expect(dead_code, reason = "path-carrying sections land with the next schema")
711 )]
712 pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf> {
713 if candidate.contains('\0') {
714 bail!("bundle path contains a NUL byte; refused");
715 }
716 let candidate_path = Path::new(candidate);
717 if candidate_path.is_absolute() {
718 bail!(
719 "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted"
720 );
721 }
722 let canonical_base = base_dir
723 .canonicalize()
724 .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?;
725 let joined = base_dir.join(candidate_path);
726 // Walk the joined path's ancestors from the deepest existing component up:
727 // every existing component must canonicalize inside the base, so a symlink
728 // pointing outside the config directory is refused even when the final
729 // target does not exist yet.
730 let deepest_existing = joined
731 .ancestors()
732 .find(|ancestor| ancestor.symlink_metadata().is_ok())
733 .context("bundle path has no existing ancestor inside the config directory")?;
734 let resolved = deepest_existing.canonicalize().with_context(|| {
735 format!(
736 "could not resolve bundle path component {}",
737 deepest_existing.display()
738 )
739 })?;
740 if !resolved.starts_with(&canonical_base) {
741 bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused");
742 }
743 Ok(joined)
744 }
745
746 // ---------------------------------------------------------------------------
747 // Remote fetch
748 // ---------------------------------------------------------------------------
749
750 /// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard
751 /// size cap, a timeout, and bounded redirects. Mirrors the skill installer's
752 /// fetch bounds.
753 pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
754 let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
755 validate_bundle_url(&current_url)?;
756 let initial_scheme = current_url.scheme().to_string();
757
758 let client = codewhale_release::platform_blocking_http_client_builder()
759 .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
760 // Redirect targets must pass the same scheme/host policy as the
761 // initial request, so redirects are followed explicitly below.
762 .redirect(reqwest::redirect::Policy::none())
763 .build()
764 .map_err(|_| anyhow!("building bundle fetch client failed"))?;
765 let mut redirects = 0usize;
766 let response = loop {
767 let response = client
768 .get(current_url.clone())
769 .send()
770 // reqwest errors can include the full URL (including its query or
771 // userinfo), so keep transport failures deliberately URL-free.
772 .map_err(|_| anyhow!("bundle fetch request failed"))?;
773
774 if !response.status().is_redirection() {
775 break response;
776 }
777 if redirects >= MAX_REDIRECTS {
778 bail!("bundle fetch exceeded the five-redirect limit");
779 }
780 let location = response
781 .headers()
782 .get(reqwest::header::LOCATION)
783 .ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))?
784 .to_str()
785 .map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?;
786 let next_url = current_url
787 .join(location)
788 .map_err(|_| anyhow!("bundle redirect Location is invalid"))?;
789 validate_bundle_redirect(&initial_scheme, &next_url)?;
790 current_url = next_url;
791 redirects += 1;
792 };
793
794 if !response.status().is_success() {
795 bail!(
796 "bundle fetch failed with HTTP status {}",
797 response.status().as_u16()
798 );
799 }
800
801 // Read at most MAX_BUNDLE_BYTES + 1 so an oversize body is detected
802 // rather than silently truncated.
803 let mut buffer = Vec::new();
804 let body = response;
805 body.take(MAX_BUNDLE_BYTES + 1)
806 .read_to_end(&mut buffer)
807 .map_err(|_| anyhow!("reading remote bundle failed"))?;
808 if buffer.len() as u64 > MAX_BUNDLE_BYTES {
809 bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
810 }
811 Ok(buffer)
812 }
813
814 fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
815 if !matches!(url.scheme(), "http" | "https") {
816 bail!("unsupported bundle URL scheme; use https");
817 }
818 if !url.username().is_empty() || url.password().is_some() {
819 bail!("bundle URLs may not include credentials");
820 }
821 let host = url.host_str().context("bundle URL must include a host")?;
822 match url.scheme() {
823 "https" => Ok(()),
824 "http" if is_loopback_bundle_host(host) => Ok(()),
825 "http" => bail!("plain http is only allowed for loopback hosts; use https"),
826 _ => unreachable!("scheme was validated above"),
827 }
828 }
829
830 fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> {
831 validate_bundle_url(next_url)?;
832 if next_url.scheme() != initial_scheme {
833 bail!("bundle redirects may not change URL scheme");
834 }
835 Ok(())
836 }
837
838 fn is_loopback_bundle_host(host: &str) -> bool {
839 let normalized = host
840 .strip_prefix('[')
841 .and_then(|value| value.strip_suffix(']'))
842 .unwrap_or(host);
843 normalized.eq_ignore_ascii_case("localhost")
844 || normalized.to_ascii_lowercase().ends_with(".localhost")
845 || normalized
846 .parse::<std::net::IpAddr>()
847 .is_ok_and(|address| address.is_loopback())
848 }
849
850 // ---------------------------------------------------------------------------
851 // Export
852 // ---------------------------------------------------------------------------
853
854 /// Build a deterministic, secret-free export from `config`.
855 ///
856 /// Keys are sorted, machine-specific absolute paths and credential fields are
857 /// dropped, and the same section mapping as import is used so an exported
858 /// bundle re-imports at the same scope.
859 pub fn export_bundle(
860 config: &ConfigToml,
861 scope: BundleScope,
862 metadata: BundleMetadata,
863 ) -> Result<PortableBundle> {
864 let mut preferences = BundleTable::default();
865 let profiles = BundleTable::default();
866 let mut global = BundleTable::default();
867 let mut project = BundleTable::default();
868
869 let mut document = config_document(config)?;
870 if scope == BundleScope::Global {
871 // Root model aliases are route-relative on import; reconcile them with
872 // the canonical provider slots so the bundle never conflicts with
873 // itself, without reparsing unrelated preserved extras as `Config`.
874 codewhale_tui::route_preferences::scrub_root_model_aliases_for_export(&mut document)?;
875 }
876 for (key, value) in document {
877 if let Some(value) = sanitize_export_value(&key, &value) {
878 match export_section_for(&key, scope) {
879 ExportSection::Preferences => {
880 preferences.entries.insert(key, value);
881 }
882 ExportSection::Global => {
883 global.entries.insert(key, value);
884 }
885 ExportSection::Project => {
886 project.entries.insert(key, value);
887 }
888 ExportSection::Drop => {}
889 }
890 }
891 }
892
893 let bundle = PortableBundle {
894 schema_version: BUNDLE_SCHEMA_VERSION,
895 kind: BUNDLE_KIND.to_string(),
896 metadata,
897 preferences,
898 profiles,
899 plugins: BundleTable::default(),
900 project,
901 global,
902 };
903 let rejected = find_rejected_entries(&bundle);
904 if !rejected.is_empty() {
905 bail!(
906 "portable export refused credential-bearing config paths: {}",
907 rejected
908 .iter()
909 .map(|entry| entry.key.as_str())
910 .collect::<Vec<_>>()
911 .join(", ")
912 );
913 }
914 Ok(bundle)
915 }
916
917 /// Serialize a bundle deterministically (sorted keys, TOML).
918 pub fn serialize_bundle(bundle: &PortableBundle) -> Result<String> {
919 toml::to_string_pretty(bundle).context("serializing portable bundle")
920 }
921
922 /// Config keys that name a machine-local location and must never be exported.
923 const MACHINE_SPECIFIC_KEYS: [&str; 14] = [
924 "base_url",
925 "bwrap_dev_roots",
926 "bwrap_ro_roots",
927 "hook_sinks.unix_socket_path",
928 "mcp_config_path",
929 "mcp_oauth_callback_port",
930 "mcp_oauth_callback_url",
931 "memory_path",
932 "network.proxy",
933 "notes_path",
934 "sandbox_backend",
935 "sandbox_url",
936 "skills_dir",
937 "telemetry_endpoint",
938 ];
939
940 enum ExportSection {
941 Preferences,
942 Global,
943 Project,
944 Drop,
945 }
946
947 fn export_section_for(key: &str, scope: BundleScope) -> ExportSection {
948 if key.starts_with("skills") || key.starts_with("tools") || key.starts_with("snapshots") {
949 return ExportSection::Preferences;
950 }
951 if key.starts_with("auth.") {
952 return ExportSection::Drop;
953 }
954 match scope {
955 BundleScope::Global => ExportSection::Global,
956 BundleScope::Project => ExportSection::Project,
957 }
958 }
959
960 fn config_document(config: &ConfigToml) -> Result<toml::map::Map<String, toml::Value>> {
961 // Serialize through TOML text before parsing to Value. Direct
962 // `Value::try_from` double-encodes datetime values held inside flattened
963 // `toml::Value` extras as the serializer's private marker table.
964 let text = toml::to_string(config).context("serializing typed config for bundle")?;
965 let value: toml::Value =
966 toml::from_str(&text).map_err(|_| anyhow!("serialized typed config was not valid TOML"))?;
967 let toml::Value::Table(mut table) = value else {
968 bail!("typed config did not serialize to a TOML table");
969 };
970 // `selected_provider_id` is runtime parse state and is skipped by serde;
971 // restore the exact named-provider identity that ConfigStore writes.
972 table.insert(
973 "provider".to_string(),
974 toml::Value::String(config.provider_id().to_string()),
975 );
976 Ok(table)
977 }
978
979 /// Return a recursively scrubbed export value. Secret-bearing leaves and
980 /// machine-local paths are omitted rather than replaced with a placeholder,
981 /// because a placeholder would become literal config on re-import.
982 fn sanitize_export_value(path: &str, value: &toml::Value) -> Option<toml::Value> {
983 sanitize_export_value_at(path, value, 0)
984 }
985
986 fn sanitize_export_value_at(path: &str, value: &toml::Value, depth: usize) -> Option<toml::Value> {
987 if depth > MAX_EXPORT_WALK_DEPTH {
988 return None;
989 }
990 if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() {
991 return None;
992 }
993 match value {
994 toml::Value::String(text) if string_secret_reason(text).is_some() => None,
995 toml::Value::Array(values) => Some(toml::Value::Array(
996 values
997 .iter()
998 .filter_map(|value| sanitize_export_value_at(path, value, depth + 1))
999 .collect(),
1000 )),
1001 toml::Value::Table(table) => {
1002 let mut scrubbed = toml::map::Map::new();
1003 for (key, value) in table {
1004 let child_path = format!("{path}.{key}");
1005 if let Some(value) = sanitize_export_value_at(&child_path, value, depth + 1) {
1006 scrubbed.insert(key.clone(), value);
1007 }
1008 }
1009 Some(toml::Value::Table(scrubbed))
1010 }
1011 _ => Some(value.clone()),
1012 }
1013 }
1014
1015 // ---------------------------------------------------------------------------
1016 // Transactional apply
1017 // ---------------------------------------------------------------------------
1018
1019 /// Outcome of a committed import.
1020 #[derive(Debug)]
1021 pub struct ImportReceipt {
1022 pub plan: ImportPlan,
1023 pub backup_path: Option<PathBuf>,
1024 pub target: PathBuf,
1025 }
1026
1027 /// Apply a validated bundle to `store` transactionally.
1028 ///
1029 /// The current document is backed up to `<target>.bundle-backup-<timestamp>-<random>`,
1030 /// the prepared candidate is committed through one `ConfigStore::save`, and any failure
1031 /// restores the backup before returning the error. The receipt redacts by
1032 /// construction: it carries only key paths and counts, never values.
1033 #[cfg(test)]
1034 pub fn apply_bundle(
1035 bundle: &PortableBundle,
1036 store: &mut codewhale_config::ConfigStore,
1037 scope: BundleScope,
1038 _workspace: &Path,
1039 ) -> Result<ImportReceipt> {
1040 let prepared = prepare_import(bundle, store, scope)?;
1041 apply_prepared_bundle(prepared, store, save_candidate)
1042 }
1043
1044 struct PreparedImport {
1045 plan: ImportPlan,
1046 candidate: ConfigToml,
1047 }
1048
1049 fn apply_prepared_bundle<F>(
1050 prepared: PreparedImport,
1051 store: &mut codewhale_config::ConfigStore,
1052 apply: F,
1053 ) -> Result<ImportReceipt>
1054 where
1055 F: FnOnce(ConfigToml, &mut codewhale_config::ConfigStore, &mut bool) -> Result<()>,
1056 {
1057 let PreparedImport { plan, candidate } = prepared;
1058 if !plan.conflicting.is_empty() {
1059 bail!(
1060 "bundle contains conflicting or rejected entries: {}; remove duplicate keys or credential-shaped entries and re-export",
1061 plan.conflicting.join(", ")
1062 );
1063 }
1064 if plan.is_no_op() {
1065 return Ok(ImportReceipt {
1066 plan,
1067 backup_path: None,
1068 target: store.path().to_path_buf(),
1069 });
1070 }
1071
1072 let target = store.path().to_path_buf();
1073 let original_config = store.config.clone();
1074 let backup_path = if target
1075 .try_exists()
1076 .with_context(|| format!("checking config target {}", target.display()))?
1077 {
1078 Some(create_collision_safe_backup(&target)?)
1079 } else {
1080 None
1081 };
1082
1083 let mut target_written = false;
1084 let apply_result = apply(candidate, store, &mut target_written);
1085 if let Err(error) = apply_result {
1086 store.config = original_config;
1087 let rollback = rollback_import_target(&target, backup_path.as_deref(), target_written)
1088 .and_then(|()| store.reload());
1089 match rollback {
1090 Ok(()) => bail!("{error:#}; rolled back to the pre-import document"),
1091 Err(_) => bail!(
1092 "{error:#}; ROLLBACK FAILED — the pre-import document is preserved at {}",
1093 backup_path
1094 .as_deref()
1095 .map(Path::display)
1096 .map(|path| path.to_string())
1097 .unwrap_or_else(
1098 || "<no prior file; remove the new target manually>".to_string()
1099 )
1100 ),
1101 }
1102 }
1103
1104 Ok(ImportReceipt {
1105 plan,
1106 backup_path,
1107 target,
1108 })
1109 }
1110
1111 fn rollback_import_target(
1112 target: &Path,
1113 backup_path: Option<&Path>,
1114 target_written: bool,
1115 ) -> Result<()> {
1116 // ConfigStore fails closed before replacing a stale target. If it did not
1117 // report a successful write, leave a concurrently-created or edited file
1118 // alone instead of mistaking somebody else's bytes for ours.
1119 if !target_written {
1120 return Ok(());
1121 }
1122 if let Some(backup_path) = backup_path {
1123 let bytes = std::fs::read(backup_path)
1124 .with_context(|| format!("reading pre-import backup {}", backup_path.display()))?;
1125 std::fs::write(target, bytes)
1126 .with_context(|| format!("restoring pre-import config {}", target.display()))?;
1127 return Ok(());
1128 }
1129
1130 match std::fs::remove_file(target) {
1131 Ok(()) => Ok(()),
1132 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1133 Err(error) => Err(error)
1134 .with_context(|| format!("removing newly-created config {}", target.display())),
1135 }
1136 }
1137
1138 /// Build the exact candidate used for both preview and commit. Legacy route
1139 /// migration remains in memory until the existing ConfigStore CAS save.
1140 fn prepare_import(
1141 bundle: &PortableBundle,
1142 store: &codewhale_config::ConfigStore,
1143 scope: BundleScope,
1144 ) -> Result<PreparedImport> {
1145 match scope {
1146 BundleScope::Global if !bundle.project.entries.is_empty() => bail!(
1147 "bundle carries [project] entries; import it with --project from the workspace instead"
1148 ),
1149 BundleScope::Project if !bundle.global.entries.is_empty() => bail!(
1150 "bundle carries [global] entries; importing them into a project document would leak machine state"
1151 ),
1152 _ => {}
1153 }
1154 validate_scope_target(scope, store.path())?;
1155 let mut plan = plan_import(bundle, &store.config, scope);
1156 if !plan.conflicting.is_empty() || (plan.is_no_op() && plan.skipped.is_empty()) {
1157 return Ok(PreparedImport {
1158 plan,
1159 candidate: store.config.clone(),
1160 });
1161 }
1162 let rendered;
1163 let original = if let Some(original) = store.original_body() {
1164 original
1165 } else {
1166 rendered = store.rendered_body()?;
1167 &rendered
1168 };
1169 let mut document = codewhale_tui::route_preferences::prepare_document(store.path(), original)?;
1170 let mut candidate = config_from_document(&document.to_string())?;
1171 let mut model_edits = Vec::<(String, String, String)>::new();
1172 let mut selected_provider = None;
1173 for (section, table) in [
1174 ("preferences", &bundle.preferences),
1175 ("profiles", &bundle.profiles),
1176 ("plugins", &bundle.plugins),
1177 ("project", &bundle.project),
1178 ("global", &bundle.global),
1179 ] {
1180 if !section_applies(section, scope) {
1181 continue;
1182 }
1183 for (key, value) in &table.entries {
1184 let dotted = format!("{section}.{key}");
1185 if nonportable_path_reason(key).is_some()
1186 || value_rejection_reason(key, value).is_some()
1187 {
1188 bail!("refusing to import non-portable config path {dotted}");
1189 }
1190 if key == "provider" {
1191 selected_provider = Some(
1192 value
1193 .as_str()
1194 .ok_or_else(|| anyhow!("config entry {dotted:?} must be a string"))?,
1195 );
1196 continue;
1197 }
1198 collect_model_edits(&dotted, key, value, &mut model_edits)?;
1199 if codewhale_tui::route_preferences::is_route_key(key) {
1200 continue;
1201 }
1202 apply_config_value(&mut candidate, key, value)?;
1203 }
1204 }
1205 document = toml::to_string(&toml::Value::Table(config_document(&candidate)?))?
1206 .parse()
1207 .map_err(|_| anyhow!("could not prepare imported configuration; contents omitted"))?;
1208 // Definitions precede the exact final selector. Root aliases then target
1209 // that selected route, so an old provider slot cannot mask an imported model.
1210 if let Some(provider) = selected_provider {
1211 codewhale_tui::route_preferences::set_document(
1212 store.path(),
1213 &mut document,
1214 "provider",
1215 provider,
1216 )?;
1217 }
1218 model_edits.sort_by_key(|(_, key, _)| !key.starts_with("providers."));
1219 for (_, key, value) in &model_edits {
1220 codewhale_tui::route_preferences::set_document(store.path(), &mut document, key, value)?;
1221 }
1222 let final_value: toml::Value = toml::from_str(&document.to_string())?;
1223 for (dotted, key, value) in &model_edits {
1224 let mut replay = document.clone();
1225 codewhale_tui::route_preferences::set_document(store.path(), &mut replay, key, value)?;
1226 if toml::from_str::<toml::Value>(&replay.to_string())? != final_value {
1227 plan.conflicting.push(dotted.clone());
1228 }
1229 }
1230 candidate = config_from_document(&document.to_string())?;
1231 // Run the same validation/serialization as the final save before consent
1232 // or backup creation. This clone never writes or replaces the CAS snapshot.
1233 let mut validation_store = store.clone();
1234 validation_store.config = candidate.clone();
1235 validation_store.rendered_body()?;
1236 if config_document(&candidate)? == config_document(&store.config)? {
1237 plan.skipped.append(&mut plan.added);
1238 plan.skipped.append(&mut plan.changed);
1239 } else {
1240 // A raw root alias may already match while its canonical provider
1241 // slot differs. Such an import is a real change, not a skipped write.
1242 if plan.is_no_op() {
1243 for (dotted, _, _) in &model_edits {
1244 plan.skipped.retain(|key| key != dotted);
1245 plan.changed.push(dotted.clone());
1246 }
1247 }
1248 let original_value: toml::Value = toml::from_str(original)?;
1249 if original_value.get("route_preferences_version").is_none()
1250 && final_value.get("route_preferences_version").is_some()
1251 {
1252 plan.added
1253 .push("global.route_preferences_version (local migration)".to_string());
1254 }
1255 }
1256 for keys in [
1257 &mut plan.added,
1258 &mut plan.changed,
1259 &mut plan.skipped,
1260 &mut plan.conflicting,
1261 ] {
1262 keys.sort();
1263 keys.dedup();
1264 }
1265 Ok(PreparedImport { plan, candidate })
1266 }
1267
1268 fn collect_model_edits(
1269 dotted: &str,
1270 key: &str,
1271 value: &toml::Value,
1272 edits: &mut Vec<(String, String, String)>,
1273 ) -> Result<()> {
1274 if key != "provider" && codewhale_tui::route_preferences::is_route_key(key) {
1275 let value = value
1276 .as_str()
1277 .ok_or_else(|| anyhow!("config entry {dotted:?} must be a string"))?;
1278 edits.push((dotted.to_string(), key.to_string(), value.to_string()));
1279 } else if (key == "providers" || key.starts_with("providers."))
1280 && let Some(table) = value.as_table()
1281 {
1282 for (child, value) in table {
1283 collect_model_edits(
1284 &format!("{dotted}.{child}"),
1285 &format!("{key}.{child}"),
1286 value,
1287 edits,
1288 )?;
1289 }
1290 }
1291 Ok(())
1292 }
1293
1294 fn config_from_document(body: &str) -> Result<ConfigToml> {
1295 let mut config: ConfigToml = toml::from_str(body).map_err(|_| {
1296 anyhow!("imported configuration has an invalid TOML type; contents omitted")
1297 })?;
1298 let document: toml::Value = toml::from_str(body)?;
1299 if let Some(provider) = document.get("provider").and_then(toml::Value::as_str) {
1300 config.bind_persisted_provider_id(provider)?;
1301 }
1302 Ok(config)
1303 }
1304
1305 fn save_candidate(
1306 candidate: ConfigToml,
1307 store: &mut codewhale_config::ConfigStore,
1308 target_written: &mut bool,
1309 ) -> Result<()> {
1310 store.config = candidate;
1311 store.save().context("saving imported bundle")?;
1312 *target_written = true;
1313 Ok(())
1314 }
1315
1316 fn apply_config_value(config: &mut ConfigToml, key: &str, value: &toml::Value) -> Result<()> {
1317 if key == "auth.mode" || key == "hook_sinks.unix_socket_path" || key.starts_with("providers.") {
1318 return config.set_value(key, &render_toml_value(value)?);
1319 }
1320
1321 let mut document = config_document(config)?;
1322 if let Some(current) = document.get_mut(key) {
1323 deep_merge_toml_value(current, value);
1324 } else {
1325 document.insert(key.to_string(), value.clone());
1326 }
1327 // As in `config_document`, round-trip through TOML text so datetimes in
1328 // flattened extras stay TOML datetimes instead of serde-private marker
1329 // tables or strings.
1330 let text = toml::to_string(&toml::Value::Table(document))
1331 .with_context(|| format!("config entry {key:?} could not be serialized"))?;
1332 *config = config_from_document(&text)?;
1333 Ok(())
1334 }
1335
1336 /// Merge a portable value into the target document without treating omitted
1337 /// table leaves as deletions. Tables recurse; arrays and scalars represent an
1338 /// explicit portable choice and replace the corresponding target value.
1339 fn deep_merge_toml_value(target: &mut toml::Value, incoming: &toml::Value) {
1340 match (target, incoming) {
1341 (toml::Value::Table(target), toml::Value::Table(incoming)) => {
1342 for (key, value) in incoming {
1343 if let Some(current) = target.get_mut(key) {
1344 deep_merge_toml_value(current, value);
1345 } else {
1346 target.insert(key.clone(), value.clone());
1347 }
1348 }
1349 }
1350 (target, incoming) => *target = incoming.clone(),
1351 }
1352 }
1353
1354 /// Render a TOML value into the scalar text `config set` accepts.
1355 fn render_toml_value(value: &toml::Value) -> Result<String> {
1356 Ok(match value {
1357 toml::Value::String(text) => text.clone(),
1358 toml::Value::Integer(number) => number.to_string(),
1359 toml::Value::Float(number) => number.to_string(),
1360 toml::Value::Boolean(flag) => flag.to_string(),
1361 toml::Value::Datetime(text) => text.to_string(),
1362 toml::Value::Array(_) | toml::Value::Table(_) => {
1363 toml::to_string(value)?.trim_end().to_string()
1364 }
1365 })
1366 }
1367
1368 fn create_collision_safe_backup(target: &Path) -> Result<PathBuf> {
1369 let timestamp = std::time::SystemTime::now()
1370 .duration_since(std::time::UNIX_EPOCH)
1371 .map(|since| since.as_secs())
1372 .unwrap_or_default();
1373 let file_name = target
1374 .file_name()
1375 .map(|name| name.to_string_lossy().into_owned())
1376 .unwrap_or_else(|| "config.toml".to_string());
1377 let parent = target.parent().unwrap_or_else(|| Path::new("."));
1378 let prefix = format!("{file_name}.bundle-backup-{timestamp}-");
1379 // NamedTempFile uses exclusive creation and restrictive initial
1380 // permissions, so concurrent same-second imports cannot clobber an older
1381 // receipt or expose config bytes before target permissions are applied.
1382 let mut backup = tempfile::Builder::new()
1383 .prefix(&prefix)
1384 .tempfile_in(parent)
1385 .with_context(|| {
1386 format!(
1387 "creating a collision-safe backup beside {}",
1388 target.display()
1389 )
1390 })?;
1391 let mut source = std::fs::File::open(target)
1392 .with_context(|| format!("opening {} for bundle backup", target.display()))?;
1393 std::io::copy(&mut source, backup.as_file_mut())
1394 .with_context(|| format!("copying {} into its bundle backup", target.display()))?;
1395 use std::io::Write as _;
1396 backup
1397 .as_file_mut()
1398 .flush()
1399 .context("flushing bundle backup")?;
1400 let permissions = source
1401 .metadata()
1402 .with_context(|| format!("reading permissions for {}", target.display()))?
1403 .permissions();
1404 std::fs::set_permissions(backup.path(), permissions)
1405 .context("preserving config permissions on bundle backup")?;
1406 backup
1407 .as_file()
1408 .sync_all()
1409 .context("syncing bundle backup")?;
1410 let (_file, path) = backup
1411 .keep()
1412 .map_err(|error| error.error)
1413 .context("persisting bundle backup")?;
1414 Ok(path)
1415 }
1416
1417 // ---------------------------------------------------------------------------
1418 // Consent
1419 // ---------------------------------------------------------------------------
1420
1421 /// Require explicit consent before mutating: interactive sessions get a
1422 /// prompt; headless runs require `--yes`.
1423 pub fn require_import_consent(yes: bool, plan: &ImportPlan) -> Result<()> {
1424 if yes {
1425 return Ok(());
1426 }
1427 if !std::io::stdin().is_terminal() {
1428 bail!(
1429 "import refused: non-interactive use requires explicit --yes after reviewing the plan"
1430 );
1431 }
1432 print!(
1433 "Apply this bundle ({} added, {} changed)? Type 'yes': ",
1434 plan.added.len(),
1435 plan.changed.len()
1436 );
1437 use std::io::Write;
1438 std::io::stdout().flush()?;
1439 let mut answer = String::new();
1440 std::io::stdin()
1441 .read_line(&mut answer)
1442 .context("reading import consent")?;
1443 if answer.trim() != "yes" {
1444 bail!("import cancelled; no configuration was changed");
1445 }
1446 Ok(())
1447 }
1448
1449 // ---------------------------------------------------------------------------
1450 // CLI surface
1451 // ---------------------------------------------------------------------------
1452
1453 /// Arguments for `codewhale config import`.
1454 #[derive(Debug, clap::Args)]
1455 pub struct ImportArgs {
1456 /// Bundle source: a file path, an HTTPS URL, or `-` for stdin.
1457 pub source: String,
1458 /// Print the deterministic import plan without writing anything.
1459 #[arg(long, default_value_t = false)]
1460 dry_run: bool,
1461 /// Skip the interactive consent prompt (required for headless use).
1462 #[arg(long, default_value_t = false)]
1463 yes: bool,
1464 /// Target the project config instead of the user-global document.
1465 #[arg(long, default_value_t = false)]
1466 project: bool,
1467 }
1468
1469 /// Arguments for `codewhale config export --portable`.
1470 #[derive(Debug, clap::Args)]
1471 pub struct ExportArgs {
1472 /// Emit a portable, secret-free bundle (required flag; plain `export`
1473 /// is reserved so a future non-portable format cannot silently change
1474 /// what the command writes).
1475 #[arg(long, default_value_t = false)]
1476 portable: bool,
1477 /// Export the project config instead of the user-global document.
1478 #[arg(long, default_value_t = false)]
1479 project: bool,
1480 /// Write to this path instead of stdout.
1481 #[arg(long, value_name = "FILE")]
1482 out: Option<PathBuf>,
1483 }
1484
1485 /// Run `config import`.
1486 pub fn run_import(
1487 args: &ImportArgs,
1488 store: &mut codewhale_config::ConfigStore,
1489 _workspace: &Path,
1490 ) -> Result<()> {
1491 let scope = if args.project {
1492 BundleScope::Project
1493 } else {
1494 BundleScope::Global
1495 };
1496 validate_scope_target(scope, store.path())?;
1497 let remote_source = args.source.starts_with("https://") || args.source.starts_with("http://");
1498 let source_label = if args.source == "-" {
1499 "stdin"
1500 } else if remote_source {
1501 "remote bundle"
1502 } else {
1503 args.source.as_str()
1504 };
1505 let raw = if args.source == "-" {
1506 let mut buffer = Vec::new();
1507 std::io::stdin()
1508 .lock()
1509 .take(MAX_BUNDLE_BYTES + 1)
1510 .read_to_end(&mut buffer)
1511 .context("reading bundle from stdin")?;
1512 if buffer.len() as u64 > MAX_BUNDLE_BYTES {
1513 bail!("stdin bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
1514 }
1515 buffer
1516 } else if remote_source {
1517 fetch_bundle(&args.source)?
1518 } else {
1519 let path = PathBuf::from(&args.source);
1520 let metadata = std::fs::metadata(&path)
1521 .with_context(|| format!("reading bundle at {}", path.display()))?;
1522 if metadata.len() > MAX_BUNDLE_BYTES {
1523 bail!(
1524 "bundle at {} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
1525 path.display(),
1526 metadata.len()
1527 );
1528 }
1529 std::fs::read(&path).with_context(|| format!("reading bundle at {}", path.display()))?
1530 };
1531
1532 let bundle = parse_bundle_bytes(&raw, source_label)?;
1533 let prepared = prepare_import(&bundle, store, scope)?;
1534 let plan = &prepared.plan;
1535
1536 println!("import plan ({} scope, {source_label}):", scope.label());
1537 println!(" added: {}", plan.added.len());
1538 println!(" changed: {}", plan.changed.len());
1539 println!(" skipped: {}", plan.skipped.len());
1540 println!(" conflicting: {}", plan.conflicting.len());
1541 println!(" rejected: {}", plan.rejected.len());
1542 for entry in &plan.added {
1543 println!(" + {entry}");
1544 }
1545 for entry in &plan.changed {
1546 println!(" ~ {entry}");
1547 }
1548 for entry in &plan.rejected {
1549 println!(" ! {} — {}", entry.key, entry.reason);
1550 }
1551 for entry in &plan.conflicting {
1552 println!(" x {entry}");
1553 }
1554
1555 if args.dry_run {
1556 println!("dry run: nothing was written");
1557 return Ok(());
1558 }
1559
1560 require_import_consent(args.yes, plan)?;
1561 let receipt = apply_prepared_bundle(prepared, store, save_candidate)?;
1562 if receipt.plan.is_no_op() {
1563 println!("nothing to apply; config already matches the bundle (idempotent re-import)");
1564 return Ok(());
1565 }
1566 println!(
1567 "imported: {} added, {} changed into {}",
1568 receipt.plan.added.len(),
1569 receipt.plan.changed.len(),
1570 receipt.target.display()
1571 );
1572 if let Some(backup) = &receipt.backup_path {
1573 println!("pre-import backup: {}", backup.display());
1574 }
1575 Ok(())
1576 }
1577
1578 /// Run `config export --portable`.
1579 pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> Result<()> {
1580 if !args.portable {
1581 bail!("config export requires --portable; plain export is not defined yet");
1582 }
1583 let scope = if args.project {
1584 BundleScope::Project
1585 } else {
1586 BundleScope::Global
1587 };
1588 validate_scope_target(scope, store.path())?;
1589 let metadata = BundleMetadata {
1590 name: None,
1591 created_at: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1592 generator: Some(format!("codewhale {}", env!("CARGO_PKG_VERSION"))),
1593 };
1594 let bundle = export_bundle(&store.config, scope, metadata)?;
1595 let body = serialize_bundle(&bundle)?;
1596 match &args.out {
1597 Some(path) => {
1598 codewhale_config::persistence::atomic_write(path, body.as_bytes())
1599 .with_context(|| format!("writing bundle to {}", path.display()))?;
1600 println!("wrote portable bundle to {}", path.display());
1601 }
1602 None => {
1603 use std::io::Write;
1604 std::io::stdout().write_all(body.as_bytes())?;
1605 }
1606 }
1607 Ok(())
1608 }
1609
1610 // ---------------------------------------------------------------------------
1611 // Tests
1612 // ---------------------------------------------------------------------------
1613
1614 #[cfg(test)]
1615 mod tests {
1616 use super::*;
1617 use codewhale_config::ConfigStore;
1618 use std::io::Write;
1619 use std::net::{Ipv4Addr, TcpListener};
1620
1621 const VALID_TOML: &str = r#"
1622 schema_version = 1
1623 kind = "codewhale.portable-config"
1624
1625 [metadata]
1626 name = "team-baseline"
1627
1628 [preferences]
1629 verbosity = "quiet"
1630 telemetry = false
1631
1632 [global]
1633 output_mode = "plain"
1634 "#;
1635
1636 fn sample_bundle() -> PortableBundle {
1637 parse_bundle_str(VALID_TOML, "test.toml").expect("valid bundle")
1638 }
1639
1640 #[test]
1641 fn valid_bundle_parses_and_validates() {
1642 let bundle = sample_bundle();
1643 assert_eq!(bundle.schema_version, 1);
1644 assert_eq!(bundle.kind, "codewhale.portable-config");
1645 assert_eq!(bundle.metadata.name.as_deref(), Some("team-baseline"));
1646 assert_eq!(bundle.preferences.entries.len(), 2);
1647 }
1648
1649 #[test]
1650 fn unknown_envelope_fields_fail_the_parse() {
1651 let text = r#"
1652 schema_version = 1
1653 kind = "codewhale.portable-config"
1654 sneaky_extra = true
1655 "#;
1656 let err = parse_bundle_str(text, "test.toml").expect_err("unknown field must fail");
1657 let rendered = format!("{err:#}");
1658 assert!(rendered.contains("unknown field"), "{rendered}");
1659 }
1660
1661 #[test]
1662 fn wrong_kind_or_schema_version_is_refused() {
1663 let bad_kind = "schema_version = 1
1664 kind = \"something-else\"\n";
1665 let err = parse_bundle_str(bad_kind, "t.toml").expect_err("kind must match");
1666 assert!(err.to_string().contains("kind"), "{err:#}");
1667
1668 let bad_version = "schema_version = 99\nkind = \"codewhale.portable-config\"\n";
1669 let err = parse_bundle_str(bad_version, "t.toml").expect_err("schema version must match");
1670 assert!(err.to_string().contains("schema_version"), "{err:#}");
1671 }
1672
1673 #[test]
1674 fn json_bundles_parse_when_the_document_is_json() {
1675 let json = r#"{"schema_version": 1, "kind": "codewhale.portable-config",
1676 "preferences": {"verbosity": "quiet"}}"#;
1677 let bundle = parse_bundle_str(json, "bundle.json").expect("json bundle");
1678 assert_eq!(bundle.preferences.entries.len(), 1);
1679 }
1680
1681 #[test]
1682 fn oversize_input_is_refused_before_parse() {
1683 let big = vec![b'#'; (MAX_BUNDLE_BYTES + 1) as usize];
1684 let err = parse_bundle_bytes(&big, "big.toml").expect_err("oversize must fail");
1685 assert!(err.to_string().contains("limit"), "{err:#}");
1686 }
1687
1688 #[test]
1689 fn credential_keys_are_rejected_by_name() {
1690 let text = r#"
1691 schema_version = 1
1692 kind = "codewhale.portable-config"
1693
1694 [global]
1695 api_key = "value-is-never-echoed"
1696
1697 [preferences]
1698 openai_api_key = "also-secret"
1699 "#;
1700 let bundle = parse_bundle_str(text, "t.toml").expect("parses");
1701 let rejected = find_rejected_entries(&bundle);
1702 assert_eq!(rejected.len(), 2, "{rejected:?}");
1703 assert!(rejected.iter().any(|r| r.key == "global.api_key"));
1704 assert!(
1705 rejected
1706 .iter()
1707 .all(|r| !r.reason.contains("value-is-never-echoed"))
1708 );
1709 }
1710
1711 #[test]
1712 fn credential_shaped_values_are_rejected_under_benign_names() {
1713 let text = r#"
1714 schema_version = 1
1715 kind = "codewhale.portable-config"
1716
1717 [preferences]
1718 note = "sk-abcdefghij0123456789"
1719 "#;
1720 let bundle = parse_bundle_str(text, "t.toml").expect("parses");
1721 let rejected = find_rejected_entries(&bundle);
1722 assert_eq!(rejected.len(), 1, "{rejected:?}");
1723 assert!(!rejected[0].reason.contains("sk-abcdefghij"));
1724 }
1725
1726 #[test]
1727 fn nested_secret_keys_and_values_are_rejected_without_echoing_values() {
1728 let shaped_value = ["Bear", "er nested-token-must-not-leak"].concat();
1729 let text = format!(
1730 r#"
1731 schema_version = 1
1732 kind = "codewhale.portable-config"
1733
1734 [preferences.with_key.nested]
1735 password = "nested-password-must-not-leak"
1736
1737 [preferences.with_value.nested]
1738 note = "{shaped_value}"
1739 "#
1740 );
1741 let bundle = parse_bundle_str(&text, "nested.toml").expect("bundle parses");
1742 let rejected = find_rejected_entries(&bundle);
1743 assert_eq!(rejected.len(), 2, "{rejected:?}");
1744 assert!(
1745 rejected
1746 .iter()
1747 .any(|entry| entry.key == "preferences.with_key")
1748 );
1749 assert!(
1750 rejected
1751 .iter()
1752 .any(|entry| entry.key == "preferences.with_value")
1753 );
1754 let rendered = format!("{rejected:?}");
1755 assert!(!rendered.contains("nested-password-must-not-leak"));
1756 assert!(!rendered.contains("nested-token-must-not-leak"));
1757 }
1758
1759 #[test]
1760 fn json_bundles_with_duplicate_keys_fail_before_parse() {
1761 let duplicate = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","verbosity":"loud"}}"#;
1762 let error = parse_bundle_str(duplicate, "dup.json").expect_err("duplicate key must fail");
1763 let rendered = format!("{error:#}");
1764 assert!(rendered.contains("duplicate key"), "{rendered}");
1765 assert!(rendered.contains("preferences.verbosity"), "{rendered}");
1766 assert!(!rendered.contains("loud"), "{rendered}");
1767
1768 let nested_array = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"list":[{"a":1,"a":2}]}}"#;
1769 let error = parse_bundle_str(nested_array, "dup-array.json")
1770 .expect_err("nested duplicate must fail");
1771 assert!(
1772 format!("{error:#}").contains("preferences.list.[0].a"),
1773 "{error:#}"
1774 );
1775
1776 let clean = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","profiles":{"verbosity":"loud"}}}"#;
1777 parse_bundle_str(clean, "clean.json").expect("same key under different parents is fine");
1778 }
1779
1780 #[test]
1781 fn network_proxy_routes_are_rejected_on_import_and_scrubbed_on_export() {
1782 let proxy_url = ["http://proxy-user:proxy-", "pass@proxy.internal:3128"].concat();
1783 let text = format!(
1784 r#"
1785 schema_version = 1
1786 kind = "codewhale.portable-config"
1787
1788 [global.network]
1789 default = "prompt"
1790 allow = ["registry.example"]
1791 proxy = ["{proxy_url}"]
1792 "#
1793 );
1794 let bundle = parse_bundle_str(&text, "network-proxy.toml").expect("bundle parses");
1795 let rejected = find_rejected_entries(&bundle);
1796 assert_eq!(rejected.len(), 1, "{rejected:?}");
1797 assert_eq!(rejected[0].key, "global.network");
1798 let rendered = format!("{rejected:?}");
1799 assert!(!rendered.contains("proxy-pass"), "{rendered}");
1800 assert!(!rendered.contains("proxy.internal"), "{rendered}");
1801
1802 let config: ConfigToml = toml::from_str(&format!(
1803 r#"
1804 [network]
1805 default = "prompt"
1806 allow = ["registry.example"]
1807 proxy = ["{proxy_url}"]
1808 "#
1809 ))
1810 .expect("network config parses");
1811 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
1812 .expect("network proxy is scrubbed");
1813 let body = serialize_bundle(&exported).expect("serialize network export");
1814 assert!(!body.contains("proxy-user"), "{body}");
1815 assert!(!body.contains("proxy.internal"), "{body}");
1816 let reparsed: toml::Value = toml::from_str(&body).expect("export reparses");
1817 let network = reparsed
1818 .get("global")
1819 .and_then(|global| global.get("network"))
1820 .expect("portable network policy is kept");
1821 assert!(network.get("proxy").is_none(), "{body}");
1822 assert_eq!(
1823 network.get("default").and_then(toml::Value::as_str),
1824 Some("prompt")
1825 );
1826 assert!(body.contains("registry.example"), "{body}");
1827 }
1828
1829 #[test]
1830 fn redaction_placeholders_are_rejected_on_import_and_export() {
1831 let placeholder = codewhale_config::persistence::REDACTED;
1832 let text = format!(
1833 r#"
1834 schema_version = 1
1835 kind = "codewhale.portable-config"
1836
1837 [preferences]
1838 verbosity = "quiet"
1839 note = "prefix {placeholder} suffix"
1840 "#
1841 );
1842 let bundle = parse_bundle_str(&text, "placeholder.toml").expect("bundle parses");
1843 let rejected = find_rejected_entries(&bundle);
1844 assert_eq!(rejected.len(), 1, "{rejected:?}");
1845 assert_eq!(rejected[0].key, "preferences.note");
1846 assert!(
1847 rejected[0].reason.contains("redaction placeholder"),
1848 "{rejected:?}"
1849 );
1850
1851 let config: ConfigToml = toml::from_str(&format!(
1852 "verbosity = \"quiet\"\nnote = \"prefix {placeholder} suffix\"\n"
1853 ))
1854 .expect("placeholder config parses");
1855 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
1856 .expect("placeholder is scrubbed");
1857 let body = serialize_bundle(&exported).expect("serialize placeholder export");
1858 assert!(!body.contains(placeholder), "{body}");
1859 assert!(body.contains("quiet"), "{body}");
1860 }
1861
1862 #[test]
1863 fn camel_case_and_dotted_secret_keys_avoid_token_count_false_positives() {
1864 for key in [
1865 "accessToken",
1866 "refreshToken",
1867 "clientSecret",
1868 "apiKey",
1869 "api.key",
1870 "private.key",
1871 "accessKey",
1872 "aws_access_key",
1873 "awsSecretAccessKey",
1874 "Cookie",
1875 "Set-Cookie",
1876 "providers.xai.auth.command",
1877 "providers.xai.external_credentials",
1878 "providers.xai.oauth_credential_generation",
1879 "nested.service.accessToken",
1880 "nested.service.refreshToken",
1881 ] {
1882 assert!(is_sensitive_bundle_key(key), "must reject {key}");
1883 }
1884 for key in [
1885 "auth_mode",
1886 "maxTokens",
1887 "tokenizer",
1888 "tokenBudget",
1889 "max_tokens",
1890 ] {
1891 assert!(!is_sensitive_bundle_key(key), "must preserve {key}");
1892 }
1893 }
1894
1895 #[test]
1896 fn compound_and_access_keys_are_rejected_before_import_without_mutation() {
1897 let api_dot = ["api", ".key"].concat();
1898 let private_dot = ["private", ".key"].concat();
1899 let access_camel = ["access", "Key"].concat();
1900 let aws_snake = ["aws", "_access_key"].concat();
1901 let aws_camel = ["aws", "SecretAccessKey"].concat();
1902 let cookie = ["Coo", "kie"].concat();
1903 let set_cookie = ["Set-", "Cookie"].concat();
1904 let text = format!(
1905 r#"
1906 schema_version = 1
1907 kind = "codewhale.portable-config"
1908
1909 [preferences]
1910 "{api_dot}" = "opaque-api-value"
1911 "{private_dot}" = "opaque-private-value"
1912 {access_camel} = "opaque-access-value"
1913 {aws_snake} = "opaque-aws-access-value"
1914 {aws_camel} = "opaque-aws-secret-access-value"
1915 maxTokens = 8192
1916 tokenizer = "bpe"
1917
1918 [preferences.http_headers]
1919 {cookie} = "opaque-cookie-import-value"
1920 {set_cookie} = "opaque-set-cookie-import-value"
1921 "#
1922 );
1923 let bundle =
1924 parse_bundle_str(&text, "compound-secrets.toml").expect("compound-key bundle parses");
1925 let rejected = find_rejected_entries(&bundle);
1926 assert_eq!(rejected.len(), 6, "{rejected:?}");
1927 assert!(
1928 rejected
1929 .iter()
1930 .any(|entry| entry.key == "preferences.http_headers"),
1931 "{rejected:?}"
1932 );
1933 assert!(
1934 rejected
1935 .iter()
1936 .all(|entry| !entry.key.contains("maxTokens") && !entry.key.contains("tokenizer")),
1937 "{rejected:?}"
1938 );
1939
1940 let dir = tempfile::tempdir().expect("config dir");
1941 let path = dir.path().join("config.toml");
1942 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
1943 let before = std::fs::read(&path).expect("config before import");
1944 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
1945 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
1946 .expect_err("credential keys must refuse the entire import");
1947 let rendered = format!("{error:#}");
1948 assert!(rendered.contains("conflicting or rejected"), "{rendered}");
1949 for secret in [
1950 "opaque-api-value",
1951 "opaque-private-value",
1952 "opaque-access-value",
1953 "opaque-aws-access-value",
1954 "opaque-aws-secret-access-value",
1955 "opaque-cookie-import-value",
1956 "opaque-set-cookie-import-value",
1957 ] {
1958 assert!(
1959 !rendered.contains(secret),
1960 "error leaked {secret}: {rendered}"
1961 );
1962 }
1963 assert_eq!(std::fs::read(path).expect("config after refusal"), before);
1964 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
1965 }
1966
1967 #[test]
1968 fn plan_reports_added_changed_skipped_deterministically() {
1969 let store = isolated_store();
1970 let bundle_text = r#"
1971 schema_version = 1
1972 kind = "codewhale.portable-config"
1973
1974 [preferences]
1975 verbosity = "quiet"
1976 log_level = "debug"
1977
1978 [global]
1979 output_mode = "plain"
1980 "#;
1981 let bundle = parse_bundle_str(bundle_text, "t.toml").expect("bundle");
1982 // verbosity already matches; log_level is new; output_mode is global-scope.
1983 let plan_global = plan_import(&bundle, &store.config, BundleScope::Global);
1984 assert!(
1985 plan_global
1986 .added
1987 .contains(&"preferences.log_level".to_string())
1988 );
1989 // `verbosity` resolves to a shipped default even when the file key is
1990 // unset, so an equal value reads as changed-or-skipped by resolution;
1991 // what matters for determinism is that every entry lands in exactly
1992 // one bucket and nothing is dropped silently.
1993 let all: std::collections::BTreeSet<&String> = plan_global
1994 .added
1995 .iter()
1996 .chain(plan_global.changed.iter())
1997 .chain(plan_global.skipped.iter())
1998 .collect();
1999 assert_eq!(all.len(), 3, "{plan_global:?}");
2000 // Project scope skips global-section entries.
2001 let plan_project = plan_import(&bundle, &store.config, BundleScope::Project);
2002 assert!(
2003 plan_project
2004 .skipped
2005 .contains(&"global.output_mode".to_string())
2006 );
2007 }
2008
2009 #[test]
2010 fn rejected_entries_show_up_as_conflicting_in_the_plan() {
2011 let store = isolated_store();
2012 let text = r#"
2013 schema_version = 1
2014 kind = "codewhale.portable-config"
2015
2016 [global]
2017 api_key = "never-echoed"
2018 "#;
2019 let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
2020 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
2021 assert!(plan.conflicting.contains(&"global.api_key".to_string()));
2022 assert!(plan.added.is_empty());
2023 }
2024
2025 #[test]
2026 fn duplicate_flat_keys_across_applicable_sections_fail_before_apply() {
2027 let mut store = isolated_store();
2028 let before = std::fs::read(store.path()).expect("config before import");
2029 let text = r#"
2030 schema_version = 1
2031 kind = "codewhale.portable-config"
2032
2033 [preferences]
2034 verbosity = "quiet"
2035
2036 [global]
2037 verbosity = "verbose"
2038 "#;
2039 let bundle = parse_bundle_str(text, "collision.toml").expect("bundle parses");
2040 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
2041
2042 assert_eq!(
2043 plan.conflicting,
2044 ["preferences.verbosity", "global.verbosity"]
2045 );
2046 assert!(plan.added.is_empty(), "{plan:?}");
2047 assert!(plan.changed.is_empty(), "{plan:?}");
2048 assert!(plan.skipped.is_empty(), "{plan:?}");
2049
2050 let workspace = tempfile::tempdir().expect("workspace");
2051 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2052 .expect_err("ambiguous flat key must fail closed");
2053 let rendered = error.to_string();
2054 assert!(rendered.contains("conflicting"), "{error:#}");
2055 assert!(!rendered.contains("quiet"), "{error:#}");
2056 assert!(!rendered.contains("verbose"), "{error:#}");
2057 assert_eq!(
2058 std::fs::read(store.path()).expect("config after refused import"),
2059 before,
2060 "collision must be refused before any write"
2061 );
2062 }
2063
2064 #[test]
2065 fn dry_run_semantics_plan_never_mutates() {
2066 let store = isolated_store();
2067 let before = std::fs::read_to_string(store.path()).expect("read config");
2068 let bundle = sample_bundle();
2069 let _prepared = prepare_import(&bundle, &store, BundleScope::Global)
2070 .expect("prepare import without writing");
2071 let after = std::fs::read_to_string(store.path()).expect("read config");
2072 assert_eq!(before, after, "planning must not write");
2073 }
2074
2075 #[test]
2076 fn route_import_prepares_migration_once_and_overrides_a_masking_provider_slot() {
2077 use crate::tests::{ScopedEnvVar, env_lock};
2078 let _env = env_lock();
2079 let home = tempfile::tempdir().expect("isolated home");
2080 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
2081 let _config_override = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
2082 let _legacy_override = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
2083 let path = home.path().join("config.toml");
2084 let original = "provider = 'zai'\ndefault_text_model = 'GLM-5.3'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2085 std::fs::write(&path, original).expect("seed config");
2086 let settings_path = home.path().join("settings.toml");
2087 let old_settings = "default_provider = 'deepseek'\n[provider_models]\ndeepseek = 'deepseek-v4-pro'\nzai = 'GLM-5.4'\n";
2088 std::fs::write(&settings_path, old_settings).expect("seed legacy choices");
2089 let bundle = parse_bundle_str(
2090 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\nprovider = 'zai'\ndefault_text_model = 'GLM-5.3'\n",
2091 "route.toml",
2092 ).expect("route bundle");
2093 let mut store = ConfigStore::load(Some(path.clone())).expect("store");
2094 let prepared = prepare_import(&bundle, &store, BundleScope::Global).expect("preview");
2095 assert!(
2096 prepared
2097 .plan
2098 .changed
2099 .iter()
2100 .any(|key| key == "global.default_text_model")
2101 );
2102 assert!(
2103 !prepared.plan.is_no_op(),
2104 "the old slot still masks the root value"
2105 );
2106 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2107 assert_eq!(
2108 std::fs::read_to_string(&settings_path).unwrap(),
2109 old_settings
2110 );
2111
2112 // Consent commits exactly the preview even if the archived input moves.
2113 let later_settings = "default_provider = 'openai'\n";
2114 std::fs::write(&settings_path, later_settings).unwrap();
2115 apply_prepared_bundle(prepared, &mut store, save_candidate).expect("commit preview");
2116 let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2117 assert_eq!(saved["provider"].as_str(), Some("zai"));
2118 assert_eq!(saved["providers"]["zai"]["model"].as_str(), Some("GLM-5.3"));
2119 assert_eq!(
2120 saved["providers"]["deepseek"]["model"].as_str(),
2121 Some("deepseek-v4-pro")
2122 );
2123 assert_eq!(saved["route_preferences_version"].as_integer(), Some(1));
2124 assert_eq!(
2125 std::fs::read_to_string(settings_path).unwrap(),
2126 later_settings
2127 );
2128 assert_eq!(
2129 codewhale_tui::route_preferences::get(&path, "provider")
2130 .unwrap()
2131 .as_deref(),
2132 Some("zai")
2133 );
2134 assert_eq!(
2135 codewhale_tui::route_preferences::get(&path, "model")
2136 .unwrap()
2137 .as_deref(),
2138 Some("GLM-5.3")
2139 );
2140 let again = prepare_import(&bundle, &store, BundleScope::Global).expect("repeat preview");
2141 assert!(again.plan.is_no_op(), "{:?}", again.plan);
2142 }
2143
2144 #[test]
2145 fn conflicting_import_model_aliases_fail_before_any_write() {
2146 let dir = tempfile::tempdir().expect("config dir");
2147 let path = dir.path().join("config.toml");
2148 let original = "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2149 std::fs::write(&path, original).unwrap();
2150 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2151 let bundle = parse_bundle_str(
2152 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\ndefault_text_model = 'GLM-5.3'\n[global.providers.zai]\nmodel = 'GLM-5.4'\n",
2153 "conflict.toml",
2154 ).unwrap();
2155 let prepared = prepare_import(&bundle, &store, BundleScope::Global).unwrap();
2156 assert!(
2157 prepared
2158 .plan
2159 .conflicting
2160 .iter()
2161 .any(|key| key == "global.providers.zai.model")
2162 );
2163 let error = apply_prepared_bundle(prepared, &mut store, save_candidate)
2164 .expect_err("conflicting aliases must be refused");
2165 assert!(error.to_string().contains("conflicting"));
2166 assert!(!error.to_string().contains("GLM-5.4"));
2167 assert_eq!(std::fs::read_to_string(path).unwrap(), original);
2168 assert_eq!(
2169 std::fs::read_dir(dir.path()).unwrap().count(),
2170 1,
2171 "no backup or staged write before validation"
2172 );
2173 }
2174
2175 #[test]
2176 fn prepared_import_keeps_configstore_cas_against_concurrent_edits() {
2177 let dir = tempfile::tempdir().expect("config dir");
2178 let path = dir.path().join("config.toml");
2179 std::fs::write(&path, "provider = 'deepseek'\n").unwrap();
2180 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2181 let prepared = prepare_import(&sample_bundle(), &store, BundleScope::Global).unwrap();
2182 let concurrent = "provider = 'openai'\n# concurrent writer\n";
2183 std::fs::write(&path, concurrent).unwrap();
2184 apply_prepared_bundle(prepared, &mut store, save_candidate)
2185 .expect_err("stale preview must fail closed");
2186 assert_eq!(std::fs::read_to_string(path).unwrap(), concurrent);
2187 }
2188
2189 #[test]
2190 fn route_migration_receipts_are_local_and_not_portable() {
2191 let bundle = parse_bundle_str(
2192 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\nroute_preferences_version = 1\n[global.route_preferences_migration]\nprevious_provider = 'zai'\n",
2193 "receipt.toml",
2194 ).unwrap();
2195 assert_eq!(find_rejected_entries(&bundle).len(), 2);
2196 let config: ConfigToml = toml::from_str(
2197 "route_preferences_version = 1\n[route_preferences_migration]\nprevious_provider = 'zai'\n",
2198 ).unwrap();
2199 let exported =
2200 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2201 assert!(
2202 !exported
2203 .global
2204 .entries
2205 .contains_key("route_preferences_version")
2206 );
2207 assert!(
2208 !exported
2209 .global
2210 .entries
2211 .contains_key("route_preferences_migration")
2212 );
2213 }
2214
2215 #[test]
2216 fn canonical_route_export_omits_shadowed_roots_and_round_trips_provider_slots() {
2217 let config: ConfigToml = toml::from_str(
2218 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-pro'\nmodel = 'old-root-model'\n[providers.zai]\nmodel = 'GLM-5.3'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[providers.openai]\nmodel = 'gpt-4.1'\n",
2219 ).unwrap();
2220 let bundle =
2221 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2222 assert!(!bundle.global.entries.contains_key("model"));
2223 assert!(!bundle.global.entries.contains_key("default_text_model"));
2224 let dir = tempfile::tempdir().expect("config dir");
2225 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2226 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2227 .expect("canonical export must import without alias conflicts");
2228 assert!(receipt.plan.conflicting.is_empty());
2229 assert_eq!(store.config.provider_id(), "zai");
2230 assert_eq!(
2231 store.config.get_value("providers.zai.model").as_deref(),
2232 Some("GLM-5.3")
2233 );
2234 assert_eq!(
2235 store
2236 .config
2237 .get_value("providers.deepseek.model")
2238 .as_deref(),
2239 Some("deepseek-v4-flash")
2240 );
2241 assert_eq!(
2242 store.config.get_value("providers.openai.model").as_deref(),
2243 Some("gpt-4.1")
2244 );
2245 let again = export_bundle(
2246 &store.config,
2247 BundleScope::Global,
2248 BundleMetadata::default(),
2249 )
2250 .unwrap();
2251 assert_eq!(
2252 serialize_bundle(&again).unwrap(),
2253 serialize_bundle(&bundle).unwrap()
2254 );
2255
2256 let root_only: ConfigToml =
2257 toml::from_str("default_text_model = 'deepseek-v4-pro'\n").unwrap();
2258 let legacy =
2259 export_bundle(&root_only, BundleScope::Global, BundleMetadata::default()).unwrap();
2260 assert_eq!(
2261 legacy
2262 .global
2263 .entries
2264 .get("default_text_model")
2265 .and_then(toml::Value::as_str),
2266 Some("deepseek-v4-pro")
2267 );
2268 let literal_custom = config_from_document(
2269 "provider = 'custom'\nbase_url = 'https://literal.example.test/v1'\ndefault_text_model = 'LiteralRootModel'\n",
2270 ).unwrap();
2271 let literal_export = export_bundle(
2272 &literal_custom,
2273 BundleScope::Global,
2274 BundleMetadata::default(),
2275 )
2276 .unwrap();
2277 assert_eq!(
2278 literal_export
2279 .global
2280 .entries
2281 .get("default_text_model")
2282 .and_then(toml::Value::as_str),
2283 Some("LiteralRootModel")
2284 );
2285 }
2286
2287 #[test]
2288 fn export_reconciles_a_legacy_root_default_model_with_the_deepseek_slot() {
2289 // Migration writes the canonical slot but never removes a legacy root
2290 // `default_model`; on import that alias also targets the DeepSeek slot,
2291 // so a raw export would conflict with itself.
2292 let config: ConfigToml = toml::from_str(
2293 "provider = 'deepseek'\ndefault_model = 'deepseek-v4-flash'\n[providers.deepseek]\nmodel = 'deepseek-v4-pro'\n",
2294 )
2295 .unwrap();
2296 let bundle =
2297 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2298 assert!(!bundle.global.entries.contains_key("default_model"));
2299 let providers = bundle
2300 .global
2301 .entries
2302 .get("providers")
2303 .and_then(toml::Value::as_table)
2304 .expect("providers table");
2305 assert_eq!(
2306 providers["deepseek"]["model"].as_str(),
2307 Some("deepseek-v4-pro")
2308 );
2309
2310 let dir = tempfile::tempdir().expect("config dir");
2311 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2312 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2313 .expect("export must import without alias conflicts");
2314 assert!(receipt.plan.conflicting.is_empty(), "{:?}", receipt.plan);
2315 assert_eq!(
2316 store
2317 .config
2318 .get_value("providers.deepseek.model")
2319 .as_deref(),
2320 Some("deepseek-v4-pro")
2321 );
2322
2323 // Without a canonical slot the root alias folds into the DeepSeek slot.
2324 let config: ConfigToml = toml::from_str(
2325 "provider = 'zai'\ndefault_model = 'deepseek-v4-flash'\n[providers.zai]\nmodel = 'GLM-5.3'\n",
2326 )
2327 .unwrap();
2328 let bundle =
2329 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2330 assert!(!bundle.global.entries.contains_key("default_model"));
2331 let providers = bundle
2332 .global
2333 .entries
2334 .get("providers")
2335 .and_then(toml::Value::as_table)
2336 .expect("providers table");
2337 assert_eq!(
2338 providers["deepseek"]["model"].as_str(),
2339 Some("deepseek-v4-flash")
2340 );
2341 assert_eq!(providers["zai"]["model"].as_str(), Some("GLM-5.3"));
2342 }
2343
2344 #[test]
2345 fn export_preserves_the_deepseek_root_fallback_when_another_route_is_active() {
2346 // With Z.ai active, a DeepSeek-id root `default_text_model` is
2347 // DeepSeek's saved fallback, not shadowed state; the active route's
2348 // canonical slot must not cause it to be dropped.
2349 let config: ConfigToml = toml::from_str(
2350 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-flash'\n[providers.zai]\nmodel = 'GLM-5.3'\n",
2351 )
2352 .unwrap();
2353 let bundle =
2354 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2355 assert!(!bundle.global.entries.contains_key("default_text_model"));
2356 let providers = bundle
2357 .global
2358 .entries
2359 .get("providers")
2360 .and_then(toml::Value::as_table)
2361 .expect("providers table");
2362 assert_eq!(
2363 providers["deepseek"]["model"].as_str(),
2364 Some("deepseek-v4-flash")
2365 );
2366 assert_eq!(providers["zai"]["model"].as_str(), Some("GLM-5.3"));
2367
2368 let dir = tempfile::tempdir().expect("config dir");
2369 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2370 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2371 .expect("export must import without alias conflicts");
2372 assert!(receipt.plan.conflicting.is_empty(), "{:?}", receipt.plan);
2373 assert_eq!(
2374 store
2375 .config
2376 .get_value("providers.deepseek.model")
2377 .as_deref(),
2378 Some("deepseek-v4-flash")
2379 );
2380
2381 // A root alias the active route still consumes stays shadowed state and
2382 // is dropped; a canonical DeepSeek leaf wins over a duplicate root
2383 // fallback.
2384 let config: ConfigToml = toml::from_str(
2385 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-pro'\nmodel = 'GLM-5.1'\n[providers.zai]\nmodel = 'GLM-5.3'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n",
2386 )
2387 .unwrap();
2388 let bundle =
2389 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2390 assert!(!bundle.global.entries.contains_key("model"));
2391 assert!(!bundle.global.entries.contains_key("default_text_model"));
2392 let providers = bundle
2393 .global
2394 .entries
2395 .get("providers")
2396 .and_then(toml::Value::as_table)
2397 .expect("providers table");
2398 assert_eq!(
2399 providers["deepseek"]["model"].as_str(),
2400 Some("deepseek-v4-flash")
2401 );
2402 }
2403
2404 #[test]
2405 fn imports_preserve_exact_builtin_shadowing_custom_provider_identity() {
2406 let dir = tempfile::tempdir().expect("config dir");
2407 let path = dir.path().join("config.toml");
2408 std::fs::write(
2409 &path,
2410 "provider = 'OpenAI'\n[providers.OpenAI]\nkind = 'openai-compatible'\nbase_url = 'https://custom.example.test/v1'\nmodel = 'LiteralOldModel'\n[providers.openai]\nmodel = 'gpt-4.1'\n",
2411 ).unwrap();
2412 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2413 for (entries, expected_model) in [
2414 ("verbosity = 'quiet'\n", "LiteralOldModel"),
2415 (
2416 "provider = 'OpenAI'\nmodel = 'LiteralNewModel'\noutput_mode = 'plain'\n",
2417 "LiteralNewModel",
2418 ),
2419 ] {
2420 let bundle = parse_bundle_str(
2421 &format!(
2422 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\n{entries}"
2423 ),
2424 "custom.toml",
2425 )
2426 .unwrap();
2427 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()).unwrap();
2428 let saved: toml::Value =
2429 toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2430 assert_eq!(saved["provider"].as_str(), Some("OpenAI"));
2431 assert_eq!(
2432 saved["providers"]["OpenAI"]["model"].as_str(),
2433 Some(expected_model)
2434 );
2435 assert_eq!(
2436 saved["providers"]["OpenAI"]["base_url"].as_str(),
2437 Some("https://custom.example.test/v1")
2438 );
2439 assert_eq!(
2440 saved["providers"]["openai"]["model"].as_str(),
2441 Some("gpt-4.1")
2442 );
2443 store.reload().unwrap();
2444 assert_eq!(
2445 store.config.provider,
2446 codewhale_config::ProviderKind::Custom
2447 );
2448 assert_eq!(store.config.provider_id(), "OpenAI");
2449 assert_eq!(
2450 codewhale_tui::route_preferences::get(&path, "provider")
2451 .unwrap()
2452 .as_deref(),
2453 Some("OpenAI")
2454 );
2455 }
2456 }
2457
2458 #[test]
2459 fn imports_preserve_regional_selector_and_canonical_model_slot() {
2460 let dir = tempfile::tempdir().expect("config dir");
2461 let path = dir.path().join("config.toml");
2462 std::fs::write(
2463 &path,
2464 "provider = 'deepseek-cn'\n[providers.deepseek_cn]\nmodel = 'deepseek-v4-pro'\n[providers.deepseek]\nmodel = 'deepseek-v4-pro'\n",
2465 ).unwrap();
2466 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2467 for (entries, expected_model) in [
2468 ("verbosity = 'quiet'\n", "deepseek-v4-pro"),
2469 (
2470 "'providers.deepseek_cn.model' = 'deepseek-v4-flash'\n",
2471 "deepseek-v4-flash",
2472 ),
2473 ] {
2474 let bundle = parse_bundle_str(
2475 &format!(
2476 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\n{entries}"
2477 ),
2478 "regional.toml",
2479 )
2480 .unwrap();
2481 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()).unwrap();
2482 let saved: toml::Value =
2483 toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2484 assert_eq!(saved["provider"].as_str(), Some("deepseek-cn"));
2485 assert_eq!(
2486 saved["providers"]["deepseek_cn"]["model"].as_str(),
2487 Some(expected_model)
2488 );
2489 assert_eq!(
2490 saved["providers"]["deepseek"]["model"].as_str(),
2491 Some("deepseek-v4-pro")
2492 );
2493 store.reload().unwrap();
2494 assert_eq!(store.config.provider_id(), "deepseek-cn");
2495 assert_eq!(
2496 codewhale_tui::route_preferences::get(&path, "model")
2497 .unwrap()
2498 .as_deref(),
2499 Some(expected_model)
2500 );
2501 let mut export_config = store.config.clone();
2502 export_config.default_text_model = Some("stale-regional-root".to_string());
2503 let exported = export_bundle(
2504 &export_config,
2505 BundleScope::Global,
2506 BundleMetadata::default(),
2507 )
2508 .unwrap();
2509 assert!(!exported.global.entries.contains_key("default_text_model"));
2510 assert_eq!(
2511 exported
2512 .global
2513 .entries
2514 .get("provider")
2515 .and_then(toml::Value::as_str),
2516 Some("deepseek-cn")
2517 );
2518 }
2519 }
2520
2521 #[test]
2522 fn apply_is_idempotent_on_reimport() {
2523 let mut store = isolated_store();
2524 let workspace = tempfile::tempdir().expect("workspace");
2525 let bundle = sample_bundle();
2526
2527 let first = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2528 .expect("first import");
2529 assert!(first.plan.added.len() + first.plan.changed.len() > 0);
2530
2531 let second = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2532 .expect("second import");
2533 assert!(
2534 second.plan.is_no_op(),
2535 "re-import must be a no-op: {:?}",
2536 second.plan
2537 );
2538 assert!(second.backup_path.is_none());
2539 }
2540
2541 #[test]
2542 fn immediate_mutating_imports_create_distinct_no_clobber_backups() {
2543 let dir = tempfile::tempdir().expect("config dir");
2544 let path = dir.path().join("config.toml");
2545 let original = b"verbosity = \"quiet\"\n";
2546 std::fs::write(&path, original).expect("seed config");
2547 #[cfg(unix)]
2548 {
2549 use std::os::unix::fs::PermissionsExt as _;
2550 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
2551 .expect("restrict target permissions");
2552 }
2553 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
2554 let first_bundle = parse_bundle_str(
2555 r#"
2556 schema_version = 1
2557 kind = "codewhale.portable-config"
2558
2559 [global]
2560 verbosity = "verbose"
2561 "#,
2562 "first.toml",
2563 )
2564 .expect("first bundle parses");
2565 let first = apply_bundle(&first_bundle, &mut store, BundleScope::Global, dir.path())
2566 .expect("first import");
2567 let first_backup = first.backup_path.expect("first backup receipt");
2568 assert_eq!(
2569 std::fs::read(&first_backup).expect("first backup"),
2570 original
2571 );
2572 let after_first = std::fs::read(&path).expect("target after first import");
2573
2574 let second_bundle = parse_bundle_str(
2575 r#"
2576 schema_version = 1
2577 kind = "codewhale.portable-config"
2578
2579 [global]
2580 output_mode = "plain"
2581 "#,
2582 "second.toml",
2583 )
2584 .expect("second bundle parses");
2585 let second = apply_bundle(&second_bundle, &mut store, BundleScope::Global, dir.path())
2586 .expect("second import");
2587 let second_backup = second.backup_path.expect("second backup receipt");
2588 assert_ne!(first_backup, second_backup, "backups must never collide");
2589 assert_eq!(
2590 std::fs::read(&second_backup).expect("second backup"),
2591 after_first,
2592 "second receipt must preserve its exact pre-import document"
2593 );
2594 assert_eq!(
2595 std::fs::read(&first_backup).expect("first backup remains"),
2596 original,
2597 "second import must not overwrite the first receipt"
2598 );
2599 for backup in [&first_backup, &second_backup] {
2600 assert!(
2601 backup
2602 .file_name()
2603 .and_then(|name| name.to_str())
2604 .is_some_and(|name| name.contains(".bundle-backup-")),
2605 "unexpected backup name: {}",
2606 backup.display()
2607 );
2608 #[cfg(unix)]
2609 {
2610 use std::os::unix::fs::PermissionsExt as _;
2611 assert_eq!(
2612 std::fs::metadata(backup)
2613 .expect("backup metadata")
2614 .permissions()
2615 .mode()
2616 & 0o777,
2617 0o600,
2618 "backup must preserve restrictive target permissions"
2619 );
2620 }
2621 }
2622 }
2623
2624 #[test]
2625 fn non_no_op_import_creates_a_missing_config_without_a_backup() {
2626 let dir = tempfile::tempdir().expect("config dir");
2627 let path = dir.path().join("config.toml");
2628 let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
2629 assert!(!path.exists(), "load must not create the config");
2630
2631 let receipt = apply_bundle(
2632 &sample_bundle(),
2633 &mut store,
2634 BundleScope::Global,
2635 dir.path(),
2636 )
2637 .expect("import creates config");
2638
2639 assert!(path.is_file(), "non-no-op import must create the config");
2640 assert!(
2641 receipt.backup_path.is_none(),
2642 "no prior file means no backup"
2643 );
2644 let reloaded = ConfigStore::load(Some(path)).expect("created config reloads");
2645 assert_eq!(reloaded.config.verbosity.as_deref(), Some("quiet"));
2646 assert_eq!(reloaded.config.output_mode.as_deref(), Some("plain"));
2647 }
2648
2649 #[test]
2650 fn failed_import_removes_a_config_created_during_the_transaction() {
2651 let dir = tempfile::tempdir().expect("config dir");
2652 let path = dir.path().join("config.toml");
2653 let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
2654
2655 let prepared =
2656 prepare_import(&sample_bundle(), &store, BundleScope::Global).expect("prepare import");
2657 let error =
2658 apply_prepared_bundle(prepared, &mut store, |candidate, store, target_written| {
2659 save_candidate(candidate, store, target_written)?;
2660 bail!("forced failure after the new document was saved")
2661 })
2662 .expect_err("forced post-save failure must roll back");
2663
2664 assert!(error.to_string().contains("rolled back"), "{error:#}");
2665 assert!(
2666 !path.exists(),
2667 "rollback must remove the newly-created file"
2668 );
2669 assert_eq!(
2670 store.config.verbosity, None,
2671 "in-memory state also rolls back"
2672 );
2673 }
2674
2675 #[test]
2676 fn project_scope_never_touches_the_global_document() {
2677 let mut store = isolated_store();
2678 let global_before = std::fs::read_to_string(store.path()).expect("global doc");
2679
2680 let text = r#"
2681 schema_version = 1
2682 kind = "codewhale.portable-config"
2683
2684 [project]
2685 approval_policy = "unless-allowed"
2686 "#;
2687 let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
2688 let ws = tempfile::tempdir().expect("ws");
2689 apply_bundle(&bundle, &mut store, BundleScope::Project, ws.path())
2690 .expect_err("project entries cannot land in a global-scoped store");
2691 let global_after = std::fs::read_to_string(store.path()).expect("global doc");
2692 assert_eq!(global_before, global_after);
2693 }
2694
2695 #[cfg(unix)]
2696 #[test]
2697 fn failed_apply_rolls_back_to_the_prior_document() {
2698 let mut store = isolated_store();
2699 let original = std::fs::read_to_string(store.path()).expect("config");
2700
2701 // A bundle whose entry fails mid-apply: `providers.deepseek.wire` is a
2702 // real key path but an invalid value for it, so set_value errors after
2703 // earlier entries were applied.
2704 let text = r#"
2705 schema_version = 1
2706 kind = "codewhale.portable-config"
2707
2708 [preferences]
2709 log_level = "debug"
2710
2711 [global]
2712 providers_deepseek_wire = "not-a-real-key-so-this-errors"
2713 "#;
2714 let _ = text;
2715 // Simpler deterministic failure: make the target file read-only.
2716 let text_ok = r#"
2717 schema_version = 1
2718 kind = "codewhale.portable-config"
2719
2720 [preferences]
2721 log_level = "debug"
2722 "#;
2723 let bundle = parse_bundle_str(text_ok, "t.toml").expect("bundle");
2724 let path = store.path().to_path_buf();
2725 // Atomic saves replace the file via rename, so the *directory* must
2726 // be made unwritable to force the write failure.
2727 use std::os::unix::fs::PermissionsExt;
2728 let dir = path.parent().expect("config dir").to_path_buf();
2729 let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
2730 perms.set_mode(0o555);
2731 std::fs::set_permissions(&dir, perms).expect("chmod dir");
2732
2733 let result = apply_bundle(&bundle, &mut store, BundleScope::Global, Path::new("."));
2734 // Restore permissions so the tempdir can be cleaned up.
2735 let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
2736 perms.set_mode(0o755);
2737 std::fs::set_permissions(&dir, perms).expect("chmod restore");
2738
2739 assert!(result.is_err(), "apply must fail on a read-only document");
2740 let restored = std::fs::read_to_string(&path).expect("config after rollback");
2741 assert_eq!(restored, original, "rollback must preserve the prior bytes");
2742 }
2743
2744 #[test]
2745 fn export_is_deterministic_and_secret_free() {
2746 let mut store = isolated_store();
2747 store
2748 .config
2749 .set_value("verbosity", "quiet")
2750 .expect("set verbosity");
2751 store
2752 .config
2753 .set_value("default_text_model", "deepseek-v4-pro")
2754 .expect("set model");
2755 store.save().expect("save");
2756
2757 let metadata = BundleMetadata::default();
2758 let one = export_bundle(&store.config, BundleScope::Global, metadata.clone())
2759 .and_then(|b| serialize_bundle(&b))
2760 .expect("export one");
2761 let two = export_bundle(&store.config, BundleScope::Global, metadata)
2762 .and_then(|b| serialize_bundle(&b))
2763 .expect("export two");
2764 assert_eq!(one, two, "export must be deterministic");
2765
2766 // No machine-specific absolute paths in the body.
2767 assert!(!one.contains("/Users/"), "{one}");
2768 assert!(!one.contains("/home/"), "{one}");
2769 }
2770
2771 #[test]
2772 fn export_preserves_typed_structured_config_and_toml_value_kinds() {
2773 let config: ConfigToml = toml::from_str(
2774 r#"
2775 provider = "deepseek"
2776 telemetry = false
2777 retry_count = 3
2778 ratio = 1.25
2779 started_at = 1979-05-27T07:32:00Z
2780 labels = ["alpha", "beta"]
2781
2782 [skills]
2783 registry_url = "https://registry.example/skills.json"
2784 max_install_size_bytes = 12345
2785
2786 [snapshots]
2787 enabled = false
2788 max_age_days = 11
2789
2790 [portable_table]
2791 enabled = true
2792 count = 4
2793 "#,
2794 )
2795 .expect("typed config parses");
2796
2797 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
2798 .expect("typed export");
2799 assert!(matches!(
2800 bundle.preferences.entries.get("skills"),
2801 Some(toml::Value::Table(_))
2802 ));
2803 assert!(matches!(
2804 bundle.preferences.entries.get("snapshots"),
2805 Some(toml::Value::Table(_))
2806 ));
2807 assert!(matches!(
2808 bundle.global.entries.get("telemetry"),
2809 Some(toml::Value::Boolean(false))
2810 ));
2811 assert!(matches!(
2812 bundle.global.entries.get("retry_count"),
2813 Some(toml::Value::Integer(3))
2814 ));
2815 assert!(matches!(
2816 bundle.global.entries.get("ratio"),
2817 Some(toml::Value::Float(value)) if *value == 1.25
2818 ));
2819 assert!(
2820 matches!(
2821 bundle.global.entries.get("started_at"),
2822 Some(toml::Value::Datetime(_))
2823 ),
2824 "{bundle:#?}"
2825 );
2826
2827 let dir = tempfile::tempdir().expect("round-trip dir");
2828 let path = dir.path().join("config.toml");
2829 let mut store = ConfigStore::load(Some(path.clone())).expect("fresh store");
2830 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2831 .expect("typed bundle imports");
2832 let reloaded = ConfigStore::load(Some(path)).expect("typed config reloads");
2833 let reexported = export_bundle(
2834 &reloaded.config,
2835 BundleScope::Global,
2836 BundleMetadata::default(),
2837 )
2838 .expect("round-trip export");
2839 assert_eq!(
2840 serialize_bundle(&reexported).expect("serialize round trip"),
2841 serialize_bundle(&bundle).expect("serialize original"),
2842 "typed portable config must round-trip without stringification or loss"
2843 );
2844 let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
2845 assert!(
2846 plan.is_no_op(),
2847 "typed re-import must be idempotent: {plan:?}"
2848 );
2849 }
2850
2851 #[test]
2852 fn typed_reimport_normalizes_omitted_serde_defaults_before_comparison() {
2853 let bundle = parse_bundle_str(
2854 r#"
2855 schema_version = 1
2856 kind = "codewhale.portable-config"
2857
2858 [preferences.snapshots]
2859 enabled = false
2860 "#,
2861 "defaults.toml",
2862 )
2863 .expect("bundle with omitted typed default");
2864 let dir = tempfile::tempdir().expect("config dir");
2865 let path = dir.path().join("config.toml");
2866 let mut store = ConfigStore::load(Some(path)).expect("fresh store");
2867 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2868 .expect("first typed import");
2869
2870 assert_eq!(
2871 store
2872 .config
2873 .snapshots
2874 .as_ref()
2875 .expect("snapshots configured")
2876 .max_age_days,
2877 7,
2878 "serde default must be materialized"
2879 );
2880 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
2881 assert!(
2882 plan.is_no_op(),
2883 "normalized re-import must be a no-op: {plan:?}"
2884 );
2885 }
2886
2887 #[test]
2888 fn telemetry_opt_out_round_trips_but_opt_in_consent_never_does() {
2889 let opted_in = ConfigToml {
2890 telemetry: Some(true),
2891 ..ConfigToml::default()
2892 };
2893 let exported = export_bundle(&opted_in, BundleScope::Global, BundleMetadata::default())
2894 .expect("opt-in export is safely omitted");
2895 assert!(
2896 !exported.global.entries.contains_key("telemetry"),
2897 "opt-in consent must not be portable: {exported:?}"
2898 );
2899
2900 let opt_in_bundle = parse_bundle_str(
2901 r#"
2902 schema_version = 1
2903 kind = "codewhale.portable-config"
2904
2905 [global]
2906 telemetry = true
2907 "#,
2908 "telemetry-opt-in.toml",
2909 )
2910 .expect("opt-in bundle parses before policy validation");
2911 let rejected = find_rejected_entries(&opt_in_bundle);
2912 assert_eq!(rejected.len(), 1, "{rejected:?}");
2913 assert!(
2914 rejected[0].reason.contains("opt-in consent"),
2915 "{rejected:?}"
2916 );
2917
2918 let dir = tempfile::tempdir().expect("config dir");
2919 let path = dir.path().join("config.toml");
2920 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
2921 let before = std::fs::read(&path).expect("config before refusal");
2922 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
2923 apply_bundle(&opt_in_bundle, &mut store, BundleScope::Global, dir.path())
2924 .expect_err("portable opt-in consent must be refused");
2925 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
2926 assert_eq!(store.config.telemetry, None);
2927
2928 let opt_out_bundle = parse_bundle_str(
2929 r#"
2930 schema_version = 1
2931 kind = "codewhale.portable-config"
2932
2933 [global]
2934 telemetry = false
2935 "#,
2936 "telemetry-opt-out.toml",
2937 )
2938 .expect("opt-out bundle parses");
2939 assert!(find_rejected_entries(&opt_out_bundle).is_empty());
2940 apply_bundle(&opt_out_bundle, &mut store, BundleScope::Global, dir.path())
2941 .expect("portable opt-out applies");
2942 assert_eq!(store.config.telemetry, Some(false));
2943 let reloaded = ConfigStore::load(Some(path)).expect("opt-out config reloads");
2944 let plan = plan_import(&opt_out_bundle, &reloaded.config, BundleScope::Global);
2945 assert!(
2946 plan.is_no_op(),
2947 "opt-out re-import must be idempotent: {plan:?}"
2948 );
2949 let reexported = export_bundle(
2950 &reloaded.config,
2951 BundleScope::Global,
2952 BundleMetadata::default(),
2953 )
2954 .expect("opt-out re-exports");
2955 assert_eq!(
2956 reexported.global.entries.get("telemetry"),
2957 Some(&toml::Value::Boolean(false))
2958 );
2959 }
2960
2961 #[test]
2962 fn structured_import_deep_merges_without_erasing_local_authority() {
2963 let dir = tempfile::tempdir().expect("config dir");
2964 let path = dir.path().join("config.toml");
2965 let api_key_name = ["api", "_key"].concat();
2966 let api_key_env_name = ["api", "_key_env"].concat();
2967 let target = format!(
2968 r#"
2969 provider = "acme_gateway"
2970
2971 [providers.acme_gateway]
2972 kind = "openai-compatible"
2973 base_url = "https://local-only.invalid/v1"
2974 model = "old-model"
2975 {api_key_name} = "opaque-local-api-value"
2976 {api_key_env_name} = "LOCAL_ACME_GATEWAY_KEY"
2977
2978 [providers.acme_gateway.auth]
2979 source = "command"
2980 command = ["/synthetic/local-credential-helper"]
2981
2982 [lsp]
2983 enabled = true
2984 include_warnings = false
2985
2986 [lsp.servers]
2987 rust = ["/synthetic/local-rust-analyzer", "--stdio"]
2988
2989 [lsp.custom.foo]
2990 language_id = "foo-language"
2991 command = "/synthetic/local-foo-lsp"
2992 args = ["--stdio"]
2993
2994 [hook_sinks]
2995 unix_socket_path = "/synthetic/local-codewhale.sock"
2996 "#
2997 );
2998 std::fs::write(&path, target).expect("seed local-authority config");
2999 let mut store = ConfigStore::load(Some(path.clone())).expect("target config loads");
3000 let bundle = parse_bundle_str(
3001 r#"
3002 schema_version = 1
3003 kind = "codewhale.portable-config"
3004
3005 [global]
3006 provider = "acme_gateway"
3007
3008 [global.providers.acme_gateway]
3009 kind = "openai-compatible"
3010 model = "new-portable-model"
3011
3012 [global.lsp]
3013 enabled = false
3014 include_warnings = true
3015 "#,
3016 "deep-merge.toml",
3017 )
3018 .expect("portable update parses");
3019 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3020
3021 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3022 .expect("portable values merge into target");
3023 assert_eq!(
3024 receipt.plan.changed,
3025 ["global.lsp", "global.providers"],
3026 "{:?}",
3027 receipt.plan
3028 );
3029 assert_eq!(
3030 receipt.plan.skipped,
3031 ["global.provider"],
3032 "{:?}",
3033 receipt.plan
3034 );
3035 assert_eq!(store.config.provider_id(), "acme_gateway");
3036 let document = config_document(&store.config).expect("merged typed document");
3037 let acme = document
3038 .get("providers")
3039 .and_then(toml::Value::as_table)
3040 .and_then(|providers| providers.get("acme_gateway"))
3041 .and_then(toml::Value::as_table)
3042 .expect("custom provider survives");
3043 assert_eq!(
3044 acme.get("model").and_then(toml::Value::as_str),
3045 Some("new-portable-model")
3046 );
3047 assert_eq!(
3048 acme.get("base_url").and_then(toml::Value::as_str),
3049 Some("https://local-only.invalid/v1")
3050 );
3051 assert_eq!(
3052 acme.get("api_key").and_then(toml::Value::as_str),
3053 Some("opaque-local-api-value")
3054 );
3055 assert_eq!(
3056 acme.get("api_key_env").and_then(toml::Value::as_str),
3057 Some("LOCAL_ACME_GATEWAY_KEY")
3058 );
3059 assert_eq!(
3060 acme.get("auth")
3061 .and_then(toml::Value::as_table)
3062 .and_then(|auth| auth.get("command"))
3063 .and_then(toml::Value::as_array)
3064 .and_then(|command| command.first())
3065 .and_then(toml::Value::as_str),
3066 Some("/synthetic/local-credential-helper")
3067 );
3068 let lsp = store.config.lsp.as_ref().expect("LSP config survives");
3069 assert_eq!(lsp.enabled, Some(false));
3070 assert_eq!(lsp.include_warnings, Some(true));
3071 assert!(lsp.servers.as_ref().is_some_and(|servers| {
3072 servers
3073 .get("rust")
3074 .is_some_and(|command| command.first().is_some_and(|part| part.contains("rust")))
3075 }));
3076 assert!(
3077 lsp.custom
3078 .as_ref()
3079 .is_some_and(|custom| custom.contains_key("foo"))
3080 );
3081 assert_eq!(
3082 store
3083 .config
3084 .hook_sinks
3085 .as_ref()
3086 .and_then(|sinks| sinks.unix_socket_path.as_deref()),
3087 Some(Path::new("/synthetic/local-codewhale.sock"))
3088 );
3089
3090 let reloaded = ConfigStore::load(Some(path)).expect("merged config reloads");
3091 assert_eq!(reloaded.config.provider_id(), "acme_gateway");
3092 let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
3093 assert!(
3094 plan.is_no_op(),
3095 "deep-merged re-import must be idempotent: {plan:?}"
3096 );
3097 }
3098
3099 #[test]
3100 fn export_recursively_drops_nested_secrets_but_keeps_safe_typed_siblings() {
3101 let provider_prefix = ["s", "k-"].concat();
3102 let bearer_prefix = ["Bear", "er "].concat();
3103 let access_key = ["access", "Token"].concat();
3104 let dotted_refresh_key = ["service.refresh", "Token"].concat();
3105 let refresh_key = ["refresh", "Token"].concat();
3106 let fixture = format!(
3107 r#"
3108 [tools]
3109 always_load = ["read_file", "{provider_prefix}nested-tool-value-must-not-leak", "write_file"]
3110
3111 [portable]
3112 safe_count = 7
3113 note = "{bearer_prefix}nested-export-value-must-not-leak"
3114 values = ["plain", "{provider_prefix}nested-array-value-must-not-leak"]
3115
3116 [portable.nested]
3117 {access_key} = "nested-export-key-must-not-leak"
3118 "{dotted_refresh_key}" = "nested-dotted-value-must-not-leak"
3119 label = "keep-me"
3120
3121 [[portable.records]]
3122 {refresh_key} = "nested-record-value-must-not-leak"
3123 count = 2
3124
3125 [[portable.records]]
3126 label = "safe-record"
3127 "#
3128 );
3129 let config: ConfigToml = toml::from_str(&fixture).expect("secret-bearing config parses");
3130 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3131 .expect("safe export");
3132 let body = serialize_bundle(&bundle).expect("serialize export");
3133 for secret in [
3134 "nested-export-value-must-not-leak",
3135 "nested-array-value-must-not-leak",
3136 "nested-tool-value-must-not-leak",
3137 "nested-export-key-must-not-leak",
3138 "nested-dotted-value-must-not-leak",
3139 "nested-record-value-must-not-leak",
3140 ] {
3141 assert!(!body.contains(secret), "export leaked {secret}: {body}");
3142 }
3143 assert!(body.contains("safe_count = 7"), "{body}");
3144 assert!(body.contains("label = \"keep-me\""), "{body}");
3145 assert!(body.contains("label = \"safe-record\""), "{body}");
3146 assert!(body.contains("values = [\"plain\"]"), "{body}");
3147 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3148
3149 let dir = tempfile::tempdir().expect("sanitized import dir");
3150 let path = dir.path().join("config.toml");
3151 let mut store = ConfigStore::load(Some(path)).expect("fresh store");
3152 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3153 .expect("sanitized typed arrays re-import");
3154 assert_eq!(
3155 store
3156 .config
3157 .tools
3158 .as_ref()
3159 .expect("tools preserved")
3160 .always_load
3161 .as_slice(),
3162 ["read_file", "write_file"],
3163 "dropping a secret array element must preserve a valid typed array"
3164 );
3165 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
3166 assert!(
3167 plan.is_no_op(),
3168 "sanitized re-import must be idempotent: {plan:?}"
3169 );
3170 }
3171
3172 #[test]
3173 fn compound_access_and_cookie_fields_are_scrubbed_on_export() {
3174 let cookie = ["Coo", "kie"].concat();
3175 let set_cookie = ["Set-", "Cookie"].concat();
3176 let api_dot = ["api", ".key"].concat();
3177 let private_dot = ["private", ".key"].concat();
3178 let access_camel = ["access", "Key"].concat();
3179 let aws_snake = ["aws", "_access_key"].concat();
3180 let aws_camel = ["aws", "SecretAccessKey"].concat();
3181 let fixture = format!(
3182 r#"
3183 [http_headers]
3184 {cookie} = "opaque-cookie-value"
3185 {set_cookie} = "opaque-set-cookie-value"
3186 X-Safe = "portable-header"
3187
3188 [portable]
3189 "{api_dot}" = "opaque-api-value"
3190 "{private_dot}" = "opaque-private-value"
3191 {access_camel} = "opaque-access-value"
3192 {aws_snake} = "opaque-aws-access-value"
3193 {aws_camel} = "opaque-aws-secret-access-value"
3194 maxTokens = 8192
3195 tokenizer = "bpe"
3196 "#
3197 );
3198 let config: ConfigToml = toml::from_str(&fixture).expect("credential-key config parses");
3199 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3200 .expect("credential fields are scrubbed");
3201 let body = serialize_bundle(&bundle).expect("serialize scrubbed export");
3202 for forbidden in [
3203 "opaque-cookie-value",
3204 "opaque-set-cookie-value",
3205 "opaque-api-value",
3206 "opaque-private-value",
3207 "opaque-access-value",
3208 "opaque-aws-access-value",
3209 "opaque-aws-secret-access-value",
3210 "api.key",
3211 "private.key",
3212 "accessKey",
3213 "aws_access_key",
3214 "awsSecretAccessKey",
3215 "Cookie",
3216 "Set-Cookie",
3217 ] {
3218 assert!(
3219 !body.contains(forbidden),
3220 "export retained {forbidden}: {body}"
3221 );
3222 }
3223 assert!(body.contains("X-Safe = \"portable-header\""), "{body}");
3224 assert!(body.contains("maxTokens = 8192"), "{body}");
3225 assert!(body.contains("tokenizer = \"bpe\""), "{body}");
3226 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3227 }
3228
3229 #[test]
3230 fn provider_credential_authority_is_rejected_on_import_and_scrubbed_on_export() {
3231 let dir = tempfile::tempdir().expect("config dir");
3232 let path = dir.path().join("config.toml");
3233 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3234 let before = std::fs::read(&path).expect("config before imports");
3235 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3236 let api_key_env_name = ["api", "_key_env"].concat();
3237
3238 for (name, body) in [
3239 (
3240 "auth",
3241 r#"
3242 [global.providers.xai.auth]
3243 source = "command"
3244 command = ["synthetic-credential-helper"]
3245 "#
3246 .to_string(),
3247 ),
3248 (
3249 "external",
3250 r#"
3251 [global.providers.xai.external_credentials]
3252 access = "read_only"
3253 provider = "xai"
3254 source = "grok_cli"
3255 path = "/synthetic/external/auth.json"
3256 consent_version = 1
3257 "#
3258 .to_string(),
3259 ),
3260 (
3261 "oauth-generation",
3262 r#"
3263 [global.providers.xai]
3264 oauth_credential_generation = "synthetic-owned-generation.toml"
3265 "#
3266 .to_string(),
3267 ),
3268 (
3269 "api-key-env",
3270 format!(
3271 r#"
3272 [global.providers.xai]
3273 {api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
3274 "#
3275 ),
3276 ),
3277 ] {
3278 let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
3279 let bundle = parse_bundle_str(&text, name).expect("authority bundle parses");
3280 assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
3281 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3282 .expect_err("authority-bearing import must fail");
3283 assert!(
3284 error.to_string().contains("conflicting or rejected"),
3285 "{name}: {error:#}"
3286 );
3287 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3288 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3289 }
3290
3291 let fixture = format!(
3292 r#"
3293 provider = "xai"
3294
3295 [providers.xai]
3296 model = "grok-safe-model"
3297 oauth_credential_generation = "synthetic-owned-generation.toml"
3298 {api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
3299
3300 [providers.xai.auth]
3301 source = "command"
3302 command = ["synthetic-credential-helper"]
3303
3304 [providers.xai.external_credentials]
3305 access = "read_only"
3306 provider = "xai"
3307 source = "grok_cli"
3308 path = "/synthetic/external/auth.json"
3309 consent_version = 1
3310 "#
3311 );
3312 let config: ConfigToml =
3313 toml::from_str(&fixture).expect("provider authority config parses");
3314 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3315 .expect("provider authority is scrubbed");
3316 let body = serialize_bundle(&exported).expect("serialize provider export");
3317 for forbidden in [
3318 "external_credentials",
3319 "oauth_credential_generation",
3320 "synthetic-credential-helper",
3321 "synthetic-owned-generation.toml",
3322 "SYNTHETIC_RANDOM_PROVIDER_KEY",
3323 api_key_env_name.as_str(),
3324 "/synthetic/external/auth.json",
3325 ] {
3326 assert!(
3327 !body.contains(forbidden),
3328 "export retained {forbidden}: {body}"
3329 );
3330 }
3331 assert!(body.contains("model = \"grok-safe-model\""), "{body}");
3332 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3333 }
3334
3335 #[test]
3336 fn machine_local_route_and_path_fields_are_rejected_and_scrubbed_symmetrically() {
3337 let bundle = parse_bundle_str(
3338 r#"
3339 schema_version = 1
3340 kind = "codewhale.portable-config"
3341
3342 [global]
3343 telemetry_endpoint = "https://synthetic.invalid/telemetry"
3344 mcpConfigPath = "/synthetic/import-mcp.json"
3345
3346 [global.providers.deepseek]
3347 baseUrl = "https://synthetic.invalid/provider/v1"
3348 model = "safe-model"
3349
3350 [global.hook_sinks]
3351 unix_socket_path = "/synthetic/import-codewhale.sock"
3352 "#,
3353 "machine-local-paths.toml",
3354 )
3355 .expect("machine-local bundle parses");
3356 let rejected = find_rejected_entries(&bundle);
3357 assert_eq!(rejected.len(), 4, "{rejected:?}");
3358 for key in [
3359 "global.telemetry_endpoint",
3360 "global.mcpConfigPath",
3361 "global.providers",
3362 "global.hook_sinks",
3363 ] {
3364 assert!(
3365 rejected.iter().any(|entry| entry.key == key),
3366 "{rejected:?}"
3367 );
3368 }
3369
3370 let dir = tempfile::tempdir().expect("config dir");
3371 let path = dir.path().join("config.toml");
3372 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3373 let before = std::fs::read(&path).expect("config before import");
3374 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3375 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3376 .expect_err("machine-local paths must refuse the entire import");
3377 assert!(
3378 error.to_string().contains("conflicting or rejected"),
3379 "{error:#}"
3380 );
3381 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3382 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3383
3384 let config: ConfigToml = toml::from_str(
3385 r#"
3386 telemetry_endpoint = "https://synthetic.invalid/telemetry"
3387 mcp_config_path = "/synthetic/export-mcp.json"
3388
3389 [providers.deepseek]
3390 base_url = "https://synthetic.invalid/provider/v1"
3391 model = "safe-model"
3392
3393 [hook_sinks]
3394 unix_socket_path = "/synthetic/export-codewhale.sock"
3395 "#,
3396 )
3397 .expect("machine-local config parses");
3398 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3399 .expect("machine-local paths are scrubbed");
3400 let body = serialize_bundle(&exported).expect("serialize machine-local export");
3401 for forbidden in [
3402 "synthetic.invalid",
3403 "/synthetic/export-mcp.json",
3404 "/synthetic/export-codewhale.sock",
3405 "telemetry_endpoint",
3406 "mcp_config_path",
3407 "unix_socket_path",
3408 "base_url",
3409 ] {
3410 assert!(
3411 !body.contains(forbidden),
3412 "export retained {forbidden}: {body}"
3413 );
3414 }
3415 assert!(body.contains("model = \"safe-model\""), "{body}");
3416 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3417 }
3418
3419 #[test]
3420 fn remaining_local_authority_is_rejected_while_safe_policy_stays_portable() {
3421 for safe in [
3422 "databaseUrl",
3423 "baseUrlTemplate",
3424 "memoryPathology",
3425 "sandboxUrlTemplate",
3426 "skills.registry_url",
3427 "network.allow",
3428 "workflow.automatic",
3429 "fleet.exec.allowed_tools",
3430 ] {
3431 assert_eq!(nonportable_path_reason(safe), None, "must preserve {safe}");
3432 }
3433
3434 let bundle = parse_bundle_str(
3435 r#"
3436 schema_version = 1
3437 kind = "codewhale.portable-config"
3438
3439 [global]
3440 instructions = "/synthetic/import-instructions.md"
3441 project_instruction_imports = "all"
3442 projectInstructionImports = "all"
3443 sandbox_backend = "synthetic-local-backend"
3444 sandboxUrl = "http://127.0.0.1:47891"
3445 bwrapRoRoots = ["/synthetic/import-ro"]
3446 bwrap_dev_roots = ["/synthetic/import-dev"]
3447 skills_dir = "/synthetic/import-skills"
3448 memoryPath = "/synthetic/import-memory.md"
3449 mcpOauthCallbackUrl = "http://127.0.0.1:47892/callback"
3450 mcp_oauth_callback_port = 47892
3451 notes_path = "/synthetic/import-notes.md"
3452
3453 [global.runtime_api]
3454 bind = "127.0.0.1:47893"
3455
3456 [global.auto_review]
3457 allow = ["synthetic-shell-action"]
3458
3459 [global.tools]
3460 always_load = ["read_file"]
3461 plugin_dir = "/synthetic/import-plugins"
3462
3463 [global.tools.overrides.shell]
3464 command = "/synthetic/import-tool-override"
3465
3466 [global.update]
3467 channel = "stable"
3468 update_uri = "file:///synthetic/import-update"
3469
3470 [global.notifications]
3471 enabled = true
3472 sound_file = "/synthetic/import-sound.wav"
3473
3474 [global.speech]
3475 enabled = true
3476 output_dir = "/synthetic/import-speech"
3477
3478 [global.skills]
3479 registry_url = "https://registry.example/skills.json"
3480
3481 [global.network]
3482 default = "prompt"
3483 allow = ["registry.example"]
3484
3485 [global.workflow]
3486 automatic = false
3487
3488 [global.fleet.exec]
3489 allowed_tools = ["read_file"]
3490 "#,
3491 "remaining-local-authority.toml",
3492 )
3493 .expect("remaining authority bundle parses");
3494 let rejected = find_rejected_entries(&bundle);
3495 assert_eq!(rejected.len(), 18, "{rejected:?}");
3496 for safe in [
3497 "global.skills",
3498 "global.network",
3499 "global.workflow",
3500 "global.fleet",
3501 ] {
3502 assert!(
3503 rejected.iter().all(|entry| entry.key != safe),
3504 "safe entry {safe} was rejected: {rejected:?}"
3505 );
3506 }
3507
3508 let dir = tempfile::tempdir().expect("config dir");
3509 let path = dir.path().join("config.toml");
3510 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3511 let before = std::fs::read(&path).expect("config before import");
3512 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3513 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3514 .expect_err("remaining local authority import must fail");
3515 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3516 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3517
3518 let config: ConfigToml = toml::from_str(
3519 r#"
3520 instructions = "/synthetic/export-instructions.md"
3521 project_instruction_imports = "all"
3522 projectInstructionImports = "all"
3523 sandbox_backend = "synthetic-local-backend"
3524 sandboxUrl = "http://127.0.0.1:47894"
3525 bwrapRoRoots = ["/synthetic/export-ro"]
3526 bwrap_dev_roots = ["/synthetic/export-dev"]
3527 skills_dir = "/synthetic/export-skills"
3528 memoryPath = "/synthetic/export-memory.md"
3529 mcpOauthCallbackUrl = "http://127.0.0.1:47895/callback"
3530 mcp_oauth_callback_port = 47895
3531 notes_path = "/synthetic/export-notes.md"
3532
3533 [runtime_api]
3534 bind = "127.0.0.1:47896"
3535
3536 [auto_review]
3537 allow = ["synthetic-shell-action"]
3538
3539 [tools]
3540 always_load = ["read_file"]
3541 plugin_dir = "/synthetic/export-plugins"
3542
3543 [tools.overrides.shell]
3544 command = "/synthetic/export-tool-override"
3545
3546 [update]
3547 channel = "stable"
3548 update_uri = "file:///synthetic/export-update"
3549
3550 [notifications]
3551 enabled = true
3552 sound_file = "/synthetic/export-sound.wav"
3553
3554 [speech]
3555 enabled = true
3556 output_dir = "/synthetic/export-speech"
3557
3558 [skills]
3559 registry_url = "https://registry.example/skills.json"
3560 max_install_size_bytes = 12345
3561
3562 [network]
3563 default = "prompt"
3564 allow = ["registry.example"]
3565
3566 [workflow]
3567 automatic = false
3568
3569 [fleet.exec]
3570 allowed_tools = ["read_file"]
3571 "#,
3572 )
3573 .expect("remaining authority config parses");
3574 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3575 .expect("remaining authority is scrubbed");
3576 for key in [
3577 "instructions",
3578 "project_instruction_imports",
3579 "projectInstructionImports",
3580 "sandbox_backend",
3581 "sandboxUrl",
3582 "bwrapRoRoots",
3583 "bwrap_dev_roots",
3584 "skills_dir",
3585 "memoryPath",
3586 "mcpOauthCallbackUrl",
3587 "mcp_oauth_callback_port",
3588 "notes_path",
3589 "runtime_api",
3590 "auto_review",
3591 ] {
3592 assert!(!exported.global.entries.contains_key(key), "retained {key}");
3593 }
3594 let body = serialize_bundle(&exported).expect("serialize safe policy export");
3595 for forbidden in [
3596 "/synthetic/export-instructions.md",
3597 "/synthetic/export-ro",
3598 "/synthetic/export-dev",
3599 "/synthetic/export-skills",
3600 "/synthetic/export-memory.md",
3601 "/synthetic/export-notes.md",
3602 "/synthetic/export-sound.wav",
3603 "/synthetic/export-speech",
3604 "file:///synthetic/export-update",
3605 "127.0.0.1:47896",
3606 ] {
3607 assert!(
3608 !body.contains(forbidden),
3609 "export retained {forbidden}: {body}"
3610 );
3611 }
3612 for safe in [
3613 "https://registry.example/skills.json",
3614 "registry.example",
3615 "automatic = false",
3616 "allowed_tools = [\"read_file\"]",
3617 "channel = \"stable\"",
3618 "enabled = true",
3619 ] {
3620 assert!(body.contains(safe), "export lost {safe}: {body}");
3621 }
3622
3623 // ToolsToml currently ignores these legacy fields while parsing, so
3624 // exercise the recursive export sanitizer directly as defense in depth.
3625 let raw: toml::Value = toml::from_str(
3626 r#"
3627 [tools]
3628 always_load = ["read_file"]
3629 plugin_dir = "/synthetic/direct-plugin-dir"
3630
3631 [tools.overrides.shell]
3632 command = "/synthetic/direct-tool-override"
3633 "#,
3634 )
3635 .expect("raw tools table parses");
3636 let scrubbed =
3637 sanitize_export_value("tools", raw.get("tools").expect("raw tools table exists"))
3638 .expect("safe tools sibling remains");
3639 let scrubbed = scrubbed.to_string();
3640 assert!(scrubbed.contains("read_file"), "{scrubbed}");
3641 assert!(!scrubbed.contains("plugin_dir"), "{scrubbed}");
3642 assert!(!scrubbed.contains("overrides"), "{scrubbed}");
3643 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3644 }
3645
3646 #[test]
3647 fn deep_nesting_fails_closed_for_rejection_and_sanitize() {
3648 fn deep_toml(depth: usize) -> toml::Value {
3649 let mut value = toml::Value::String("leaf".to_string());
3650 for _ in 0..depth {
3651 let mut map = toml::map::Map::new();
3652 map.insert("t".to_string(), value);
3653 value = toml::Value::Table(map);
3654 }
3655 value
3656 }
3657
3658 let deep = deep_toml(70);
3659 let reason = value_rejection_reason("t", &deep).expect("over-deep value must be rejected");
3660 assert!(reason.contains("levels deep"), "{reason}");
3661 assert!(
3662 sanitize_export_value("t", &deep)
3663 .is_some_and(|scrubbed| !scrubbed.to_string().contains("leaf")),
3664 "over-deep branch must be omitted, not exported"
3665 );
3666 }
3667
3668 #[test]
3669 fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() {
3670 let config: ConfigToml = toml::from_str(
3671 r#"
3672 [lsp]
3673 enabled = true
3674 poll_after_edit_ms = 250
3675 max_diagnostics_per_file = 12
3676 include_warnings = true
3677
3678 [lsp.servers]
3679 rust = ["/synthetic/rust-analyzer", "--stdio"]
3680
3681 [lsp.custom.foo]
3682 language_id = "foo-language"
3683 command = "/synthetic/foo-language-server"
3684 args = ["--stdio", "--synthetic"]
3685 "#,
3686 )
3687 .expect("LSP config parses");
3688 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3689 .expect("LSP executable authority is scrubbed");
3690 let body = serialize_bundle(&exported).expect("serialize LSP export");
3691 for forbidden in [
3692 "/synthetic/rust-analyzer",
3693 "/synthetic/foo-language-server",
3694 "foo-language",
3695 "--stdio",
3696 "--synthetic",
3697 ] {
3698 assert!(
3699 !body.contains(forbidden),
3700 "export retained {forbidden}: {body}"
3701 );
3702 }
3703 for inert in [
3704 "enabled = true",
3705 "poll_after_edit_ms = 250",
3706 "max_diagnostics_per_file = 12",
3707 "include_warnings = true",
3708 ] {
3709 assert!(body.contains(inert), "export lost {inert}: {body}");
3710 }
3711 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3712
3713 let dir = tempfile::tempdir().expect("config dir");
3714 let path = dir.path().join("config.toml");
3715 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3716 let before = std::fs::read(&path).expect("config before imports");
3717 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3718 for (name, body) in [
3719 (
3720 "servers",
3721 r#"
3722 [global.lsp]
3723 enabled = true
3724
3725 [global.lsp.servers]
3726 rust = ["/synthetic/import-rust-analyzer", "--stdio"]
3727 "#,
3728 ),
3729 (
3730 "custom",
3731 r#"
3732 [global.lsp]
3733 include_warnings = true
3734
3735 [global.lsp.custom.foo]
3736 language_id = "foo-language"
3737 command = "/synthetic/import-foo-server"
3738 args = ["--stdio"]
3739 "#,
3740 ),
3741 ] {
3742 let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
3743 let bundle = parse_bundle_str(&text, name).expect("LSP bundle parses");
3744 assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
3745 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3746 .expect_err("LSP executable authority import must fail");
3747 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3748 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3749 }
3750 }
3751
3752 #[test]
3753 fn machine_bound_authority_subtrees_are_rejected_and_never_exported() {
3754 let config: ConfigToml = toml::from_str(
3755 r#"
3756 managed_config_path = "/synthetic/managed-config.toml"
3757 requirements_path = "/synthetic/requirements.md"
3758
3759 [workspace]
3760 root = "/synthetic/workspace-root"
3761 trust = "trusted"
3762 allow_shell = true
3763
3764 [projects."/synthetic/project-root"]
3765 trust = "trusted"
3766 allow_shell = true
3767
3768 [hooks.session_start]
3769 command = "/synthetic/session-start"
3770
3771 [portable.workspace]
3772 label = "safe-nested-workspace-label"
3773
3774 [portable.projects]
3775 label = "safe-nested-projects-label"
3776
3777 [portable.hooks]
3778 label = "safe-nested-hooks-label"
3779 "#,
3780 )
3781 .expect("machine-bound authority config parses");
3782 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3783 .expect("machine-bound authority is scrubbed");
3784 assert!(!exported.global.entries.contains_key("workspace"));
3785 assert!(!exported.global.entries.contains_key("projects"));
3786 assert!(!exported.global.entries.contains_key("hooks"));
3787 assert!(!exported.global.entries.contains_key("managed_config_path"));
3788 assert!(!exported.global.entries.contains_key("requirements_path"));
3789 let body = serialize_bundle(&exported).expect("serialize machine-bound export");
3790 for path in [
3791 "/synthetic/workspace-root",
3792 "/synthetic/project-root",
3793 "/synthetic/session-start",
3794 "/synthetic/managed-config.toml",
3795 "/synthetic/requirements.md",
3796 ] {
3797 assert!(!body.contains(path), "export retained {path}: {body}");
3798 }
3799 assert!(body.contains("safe-nested-workspace-label"), "{body}");
3800 assert!(body.contains("safe-nested-projects-label"), "{body}");
3801 assert!(body.contains("safe-nested-hooks-label"), "{body}");
3802
3803 let bundle = parse_bundle_str(
3804 r#"
3805 schema_version = 1
3806 kind = "codewhale.portable-config"
3807
3808 [global]
3809 managed_config_path = "/synthetic/import-managed-config.toml"
3810 requirements_path = "/synthetic/import-requirements.md"
3811
3812 [global.workspace]
3813 root = "/synthetic/import-workspace"
3814 trust = "trusted"
3815 allow_shell = true
3816
3817 [global.projects."/synthetic/import-project"]
3818 trust = "trusted"
3819 allow_shell = true
3820
3821 [global.hooks.session_start]
3822 command = "/synthetic/import-session-start"
3823
3824 [global.portable.workspace]
3825 label = "safe-nested-workspace-label"
3826 "#,
3827 "machine-bound-authority.toml",
3828 )
3829 .expect("machine-bound authority bundle parses");
3830 let rejected = find_rejected_entries(&bundle);
3831 assert_eq!(rejected.len(), 5, "{rejected:?}");
3832 assert!(rejected.iter().any(|entry| entry.key == "global.workspace"));
3833 assert!(rejected.iter().any(|entry| entry.key == "global.projects"));
3834 assert!(rejected.iter().any(|entry| entry.key == "global.hooks"));
3835 assert!(
3836 rejected
3837 .iter()
3838 .any(|entry| entry.key == "global.managed_config_path")
3839 );
3840 assert!(
3841 rejected
3842 .iter()
3843 .any(|entry| entry.key == "global.requirements_path")
3844 );
3845
3846 let dir = tempfile::tempdir().expect("config dir");
3847 let path = dir.path().join("config.toml");
3848 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3849 let before = std::fs::read(&path).expect("config before import");
3850 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3851 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3852 .expect_err("machine-bound authority import must fail");
3853 assert_eq!(std::fs::read(path).expect("config after refusal"), before);
3854 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3855 }
3856
3857 #[test]
3858 fn bundle_scope_must_match_the_target_for_import_and_export() {
3859 let dir = tempfile::tempdir().expect("config dir");
3860 let global_path = dir.path().join("config.toml");
3861 let error = validate_scope_target(BundleScope::Project, &global_path)
3862 .expect_err("global path cannot masquerade as project scope");
3863 assert!(error.to_string().contains("workspace config"), "{error:#}");
3864 validate_scope_target(BundleScope::Global, &global_path)
3865 .expect("global config accepts global scope");
3866
3867 let project_path = dir.path().join(".codewhale").join("config.toml");
3868 std::fs::create_dir(dir.path().join(".git")).expect("checkout marker");
3869 validate_scope_target(BundleScope::Project, &project_path)
3870 .expect("workspace config accepts project scope");
3871 let error = validate_scope_target(BundleScope::Global, &project_path)
3872 .expect_err("workspace path cannot masquerade as global scope");
3873 assert!(error.to_string().contains("--project"), "{error:#}");
3874
3875 let mut store = ConfigStore::load(Some(project_path)).expect("workspace store loads");
3876 let import = ImportArgs {
3877 source: dir
3878 .path()
3879 .join("must-not-be-read.toml")
3880 .display()
3881 .to_string(),
3882 dry_run: true,
3883 yes: true,
3884 project: false,
3885 };
3886 let error = run_import(&import, &mut store, dir.path())
3887 .expect_err("global import must refuse a workspace config before reading input");
3888 assert!(error.to_string().contains("--project"), "{error:#}");
3889
3890 let output = dir.path().join("must-not-be-written.toml");
3891 let export = ExportArgs {
3892 portable: true,
3893 project: false,
3894 out: Some(output.clone()),
3895 };
3896 let error = run_export(&export, &store)
3897 .expect_err("global export must refuse a workspace config before writing output");
3898 assert!(error.to_string().contains("--project"), "{error:#}");
3899 assert!(!output.exists(), "refused export must not create an output");
3900 }
3901
3902 #[test]
3903 fn exported_bundle_reimports_cleanly() {
3904 let mut store = isolated_store();
3905 store.config.set_value("verbosity", "quiet").expect("set");
3906 store.save().expect("save");
3907
3908 let bundle = export_bundle(
3909 &store.config,
3910 BundleScope::Global,
3911 BundleMetadata::default(),
3912 )
3913 .expect("export");
3914 let rejected = find_rejected_entries(&bundle);
3915 assert!(
3916 rejected.is_empty(),
3917 "export must be secret-free: {rejected:?}"
3918 );
3919
3920 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
3921 assert!(
3922 plan.rejected.is_empty() && plan.conflicting.is_empty(),
3923 "own export must not trip rejection: {plan:?}"
3924 );
3925 }
3926
3927 #[test]
3928 fn http_non_loopback_fetch_is_refused_without_network_access() {
3929 let err = fetch_bundle("http://example.com/bundle.toml")
3930 .expect_err("plain http to a public host must be refused");
3931 assert!(err.to_string().contains("loopback"), "{err:#}");
3932 assert!(!err.to_string().contains("example.com"), "{err:#}");
3933 }
3934
3935 #[test]
3936 fn redirect_to_non_loopback_http_is_refused_without_leaking_location() {
3937 let secret = "location-secret-must-not-leak";
3938 let location = format!("http://example.com/internal?token={secret}");
3939 let response = http_response("302 Found", &[("Location", location.as_str())], b"");
3940 let (url, server) = spawn_bundle_http_server(vec![response]);
3941
3942 let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
3943 let rendered = format!("{error:#}");
3944 assert!(rendered.contains("loopback"), "{rendered}");
3945 assert!(!rendered.contains("example.com"), "{rendered}");
3946 assert!(!rendered.contains(secret), "{rendered}");
3947 assert_eq!(server.join().expect("server joins"), 1);
3948 }
3949
3950 #[test]
3951 fn https_redirect_cannot_downgrade_to_loopback_http() {
3952 let secret = "downgrade-secret-must-not-leak";
3953 let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}"))
3954 .expect("test target URL");
3955
3956 let error = validate_bundle_redirect("https", &target)
3957 .expect_err("HTTPS redirect must not downgrade to loopback HTTP");
3958 let rendered = format!("{error:#}");
3959 assert!(rendered.contains("scheme"), "{rendered}");
3960 assert!(!rendered.contains(secret), "{rendered}");
3961 assert!(!rendered.contains("127.0.0.1"), "{rendered}");
3962 }
3963
3964 #[test]
3965 fn relative_loopback_redirect_fetches_bundle() {
3966 let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b"");
3967 let body = VALID_TOML.as_bytes();
3968 let success = http_response("200 OK", &[("Content-Type", "text/plain")], body);
3969 let (url, server) = spawn_bundle_http_server(vec![redirect, success]);
3970
3971 let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
3972 assert_eq!(fetched, body);
3973 assert_eq!(server.join().expect("server joins"), 2);
3974 }
3975
3976 #[test]
3977 fn redirect_limit_is_enforced_before_a_sixth_hop() {
3978 let responses = (0..=MAX_REDIRECTS)
3979 .map(|hop| {
3980 let location = format!("/hop-{}", hop + 1);
3981 http_response("302 Found", &[("Location", location.as_str())], b"")
3982 })
3983 .collect();
3984 let (url, server) = spawn_bundle_http_server(responses);
3985
3986 let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
3987 assert!(error.to_string().contains("five-redirect"), "{error:#}");
3988 assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1);
3989 }
3990
3991 #[test]
3992 fn bundle_url_credentials_are_rejected_without_echoing_them() {
3993 let secret = "credential-secret-must-not-leak";
3994 let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
3995 .expect_err("URL userinfo must be refused");
3996 let rendered = format!("{error:#}");
3997 assert!(rendered.contains("credentials"), "{rendered}");
3998 assert!(!rendered.contains(secret), "{rendered}");
3999 assert!(!rendered.contains("example.com"), "{rendered}");
4000 }
4001
4002 #[test]
4003 fn unsupported_schemes_are_refused() {
4004 let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
4005 assert!(err.to_string().contains("scheme"), "{err:#}");
4006 }
4007
4008 #[test]
4009 fn headless_import_requires_yes() {
4010 let plan = ImportPlan {
4011 added: vec!["preferences.x".to_string()],
4012 ..ImportPlan::default()
4013 };
4014 // The test harness runs headless (no tty), so consent without --yes
4015 // must refuse before any prompt.
4016 let err = require_import_consent(false, &plan).expect_err("headless needs --yes");
4017 assert!(err.to_string().contains("--yes"), "{err:#}");
4018 require_import_consent(true, &plan).expect("--yes short-circuits consent");
4019 }
4020
4021 #[test]
4022 fn bounded_paths_refuse_traversal_and_absolute_escapes() {
4023 let base = tempfile::tempdir().expect("base");
4024 let err =
4025 resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused");
4026 assert!(
4027 err.to_string().contains("escapes") || err.to_string().contains("absolute"),
4028 "{err:#}"
4029 );
4030 let absolute = base.path().join("absolute.toml");
4031 let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref())
4032 .expect_err("absolute refused");
4033 assert!(err.to_string().contains("absolute"), "{err:#}");
4034 let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok");
4035 assert!(ok.starts_with(base.path()));
4036 }
4037
4038 // -- helpers ------------------------------------------------------------
4039
4040 fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
4041 let mut response = format!(
4042 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
4043 body.len()
4044 )
4045 .into_bytes();
4046 for (name, value) in headers {
4047 response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
4048 }
4049 response.extend_from_slice(b"\r\n");
4050 response.extend_from_slice(body);
4051 response
4052 }
4053
4054 fn spawn_bundle_http_server(
4055 responses: Vec<Vec<u8>>,
4056 ) -> (String, std::thread::JoinHandle<usize>) {
4057 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind HTTP fixture");
4058 let address = listener.local_addr().expect("fixture address");
4059 let handle = std::thread::spawn(move || {
4060 let mut served = 0usize;
4061 for response in responses {
4062 let (mut stream, _) = listener.accept().expect("accept fixture request");
4063 stream
4064 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
4065 .expect("fixture read timeout");
4066 let mut request = Vec::new();
4067 let mut chunk = [0_u8; 1024];
4068 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
4069 let read = stream.read(&mut chunk).expect("read fixture request");
4070 if read == 0 {
4071 break;
4072 }
4073 request.extend_from_slice(&chunk[..read]);
4074 }
4075 stream.write_all(&response).expect("write fixture response");
4076 served += 1;
4077 }
4078 served
4079 });
4080 (format!("http://{address}/start"), handle)
4081 }
4082
4083 /// A store over a config file that outlives the helper: the tempdir is
4084 /// leaked deliberately (tests are short-lived; explicit cleanup would need
4085 /// to thread the guard through every call site).
4086 fn isolated_store() -> ConfigStore {
4087 // Serialize with every other env-mutating test in this crate: a private
4088 // lock here would still race `ScopedEnvVar` users (observed as a flaky
4089 // credentials-dir failure in `api_key_config_failure_restores_*`).
4090 let _guard = crate::tests::env_lock();
4091
4092 let dir = {
4093 // TempDir::keep() is the non-deprecated ownership transfer.
4094 let temp = tempfile::TempDir::new().expect("tempdir");
4095 temp.keep()
4096 };
4097 let unique = dir.join("home").join(std::process::id().to_string());
4098 std::fs::create_dir_all(&unique).expect("unique home");
4099 // SAFETY: test-only env mutation, serialized by the lock above.
4100 unsafe { std::env::set_var("CODEWHALE_HOME", &unique) };
4101 let path = unique.join("config.toml");
4102 std::fs::write(&path, "# test config\n").expect("seed config file");
4103 ConfigStore::load(Some(path)).expect("store loads")
4104 }
4105 }
4106
4106 lines RUST