Skip to main content

epicenter_cli/
config.rs

1//! Editing Cargo's global `config.toml` — the single source of truth.
2//!
3//! epicenter keeps NO state file of its own: the shared-cache decision lives
4//! in `$CARGO_HOME/config.toml` as the `[build] build-dir` key, where cargo
5//! (and any other tool) can read it. epicenter writes that key (`setup`),
6//! reads it back, and verifies it (`doctor`).
7
8use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::paths::{self, DEFAULT_BUILD_DIR};
13
14/// The effective shared-cache root. This is the **global** trust anchor
15/// (`$CARGO_HOME/config.toml`, or the canonical default); project configs are
16/// read only to detect and refuse a shadowing `build-dir`. `cargo metadata`
17/// is deliberately not executed (it would trust/execute project config).
18pub fn cache_root() -> Result<PathBuf> {
19    // Security: never ask `cargo metadata` in the current directory. In a
20    // repository, project `.cargo/config.toml` (including `build.rustc`)
21    // is attacker-controlled and would both redirect the vault and execute
22    // code during resolution. The global `$CARGO_HOME/config.toml` is the
23    // only trust anchor.
24    let trusted = trusted_cache_root()?;
25    if let Some((path, value)) = project_build_dir_override()? {
26        let global =
27            cargo_configured("build-dir")?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
28        if value != global {
29            anyhow::bail!(
30                "refusing a project-controlled cache root: {} sets build-dir = {:?}, but the trusted global build-dir is {:?}. Move the decision to $CARGO_HOME/config.toml; epicenter never uses a project-controlled vault.",
31                path.display(),
32                value,
33                global
34            );
35        }
36    }
37    Ok(trusted)
38}
39
40/// The effective project config is the deepest ancestor `.cargo/config*`.
41/// Read it as text (never execute it); a differing `build-dir` is refused.
42fn project_build_dir_override() -> Result<Option<(PathBuf, String)>> {
43    let global = cargo_config_path().ok();
44    let cwd = std::env::current_dir().context("resolving the current directory")?;
45    for dir in cwd.ancestors() {
46        for name in ["config.toml", "config"] {
47            let candidate = dir.join(".cargo").join(name);
48            if global.as_ref() == Some(&candidate) || !candidate.is_file() {
49                continue;
50            }
51            let Ok(text) = crate::util::read_small_text_follow(&candidate, 1024 * 1024) else {
52                continue;
53            };
54            if let Some(value) = read_build_key(&text, "build-dir") {
55                return Ok(Some((candidate, value)));
56            }
57        }
58    }
59    Ok(None)
60}
61
62/// The trusted root: the global `$CARGO_HOME/config.toml` `build-dir` value,
63/// or the canonical default template. Never reads a project config.
64pub fn trusted_cache_root() -> Result<PathBuf> {
65    let value = cargo_configured("build-dir")?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
66    paths::resolve_template(&value)
67}
68
69/// Config values are written inside single-quoted TOML strings. Reject the
70/// characters that can break out of the literal or start a new key/line.
71fn validate_config_value(value: &str, what: &str) -> Result<()> {
72    if value.contains(['\n', '\r', '\0', '\'']) {
73        anyhow::bail!("{what} contains a forbidden character (newline, NUL or single quote)");
74    }
75    Ok(())
76}
77
78/// Cargo's *global* config file: `$CARGO_HOME/config.toml`.
79/// Settings there apply to every project on the machine.
80pub fn cargo_config_path() -> Result<PathBuf> {
81    Ok(paths::cargo_home()?.join("config.toml"))
82}
83
84/// What an edit to Cargo's config changed.
85#[derive(Debug, PartialEq, Eq)]
86pub enum CargoEdit {
87    /// The file did not exist and was created.
88    Created,
89    /// The key was added to an existing `[build]` table.
90    Inserted,
91    /// The key existed with a different value and was replaced.
92    Replaced,
93    /// The key existed and was deleted.
94    Removed,
95    /// Nothing needed changing.
96    AlreadySet,
97    /// The key was appendend at the end of the toml file
98    Appended,
99}
100
101impl CargoEdit {
102    pub fn describe(&self) -> &'static str {
103        match self {
104            CargoEdit::Created => "created the file",
105            CargoEdit::Inserted => "added",
106            CargoEdit::Replaced => "updated",
107            CargoEdit::Removed => "removed",
108            CargoEdit::AlreadySet => "already set, unchanged",
109            CargoEdit::Appended => "appended line at the EOF",
110        }
111    }
112}
113
114/// Sets `key = 'value'` inside the `[build]` table of a Cargo config,
115/// as pure text surgery so user comments and formatting survive untouched.
116fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
117    let wanted = format!("{key} = '{value}'");
118    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
119
120    // Exact header match (whitespace-insensitive) — `.contains("[build]")`
121    // would also match comments or sub-table names.
122    let header = lines
123        .iter()
124        .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
125
126    match header {
127        Some(i) => {
128            // Scan to the next table header: that is the whole [build] table.
129            let mut j = i + 1;
130            while j < lines.len() {
131                let t = lines[j].trim();
132                if t.starts_with('[') && t.ends_with(']') {
133                    break;
134                }
135                if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
136                    if t == wanted {
137                        return (text.to_string(), CargoEdit::AlreadySet);
138                    }
139                    lines[j] = wanted;
140                    return (lines.join("\n") + "\n", CargoEdit::Replaced);
141                }
142                j += 1;
143            }
144            lines.insert(i + 1, wanted);
145            (lines.join("\n") + "\n", CargoEdit::Inserted)
146        }
147        None => {
148            let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
149            if needs_blank {
150                lines.push(String::new());
151            }
152            lines.push("[build]".to_string());
153            lines.push(wanted);
154            (lines.join("\n") + "\n", CargoEdit::Appended)
155        }
156    }
157}
158
159/// delete `key = ...` from the `[build]` table, if present.
160fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
161    let mut out: Vec<String> = Vec::new();
162    let mut in_build = false;
163    let mut removed = false;
164    for line in text.lines() {
165        let trimmed = line.trim();
166        if trimmed.starts_with('[') && trimmed.ends_with(']') {
167            in_build = trimmed.replace([' ', '\t'], "") == "[build]";
168            out.push(line.to_string());
169            continue;
170        }
171        if in_build
172            && trimmed
173                .split_once('=')
174                .map_or(false, |(candidate, _)| candidate.trim() == key)
175        {
176            removed = true;
177            continue;
178        }
179        out.push(line.to_string());
180    }
181    if removed {
182        (out.join("\n") + "\n", CargoEdit::Removed)
183    } else {
184        (text.to_string(), CargoEdit::AlreadySet)
185    }
186}
187/// Parse the value of `key` out of the `[build]` table.
188fn read_build_key(text: &str, key: &str) -> Option<String> {
189    read_table_key(text, "build", key)
190}
191
192/// Strip a trailing `#` comment while respecting single/double quoted values.
193fn strip_toml_comment(value: &str) -> &str {
194    let mut quote: Option<char> = None;
195    let mut escaped = false;
196    for (index, ch) in value.char_indices() {
197        if escaped {
198            escaped = false;
199            continue;
200        }
201        match quote {
202            Some(q) => {
203                if ch == '\\' && q == '"' {
204                    escaped = true;
205                } else if ch == q {
206                    quote = None;
207                }
208            }
209            None => {
210                if ch == '#' {
211                    return &value[..index];
212                }
213                if ch == '"' || ch == '\'' {
214                    quote = Some(ch);
215                }
216            }
217        }
218    }
219    value
220}
221
222/// Parse `key = value` out of any `[table]` of a cargo config text.
223///
224/// Only the exact table is scanned (until the next header line); a trailing
225/// comment is stripped and surrounding quotes are removed.
226pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
227    let header = format!("[{table}]");
228    let mut in_table = false;
229    for line in text.lines() {
230        let trimmed = line.trim();
231        if trimmed.starts_with('[') && trimmed.ends_with(']') {
232            in_table = trimmed.replace([' ', '\t'], "") == header;
233            continue;
234        }
235        if !in_table {
236            continue;
237        }
238        if let Some((candidate, value)) = trimmed.split_once('=') {
239            if candidate.trim() != key {
240                continue;
241            }
242            let value = strip_toml_comment(value).trim();
243            let value = value
244                .strip_prefix('\'')
245                .and_then(|v| v.strip_suffix('\''))
246                .or_else(|| value.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
247                .unwrap_or(value);
248            return Some(value.trim().to_string());
249        }
250    }
251    None
252}
253
254/// Look up a key in any table of Cargo's *global* config file.
255pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
256    let path = cargo_config_path()?;
257    if !path.exists() {
258        return Ok(None);
259    }
260    let text = crate::util::read_small_text_follow(&path, 1024 * 1024)?;
261    Ok(read_table_key(&text, table, key))
262}
263
264/// Look up a `[build]` key in Cargo's *global* config file on disk.
265pub fn cargo_configured(key: &str) -> Result<Option<String>> {
266    let path = cargo_config_path()?;
267    if !path.exists() {
268        return Ok(None);
269    }
270    let text = crate::util::read_small_text_follow(&path, 1024 * 1024)?;
271    Ok(read_build_key(&text, key))
272}
273
274/// Read a `[build]` key from ANY cargo config file — doctor uses this to scan
275/// the ancestor `.cargo/config.toml` chain for shadowing values. Read-only.
276pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
277    let text = crate::util::read_small_text_follow(path, 1024 * 1024).ok()?;
278    read_build_key(&text, key)
279}
280
281pub struct SetupReport {
282    /// Where the shared cache now lives.
283    pub root: PathBuf,
284    /// What happened to the `build-dir` setting.
285    pub build_dir_edit: CargoEdit,
286    /// What happened to the `rustc-wrapper` (kache/sccache) setting, if touched.
287    pub wrapper_edit: Option<CargoEdit>,
288    /// Backup of the previous cargo config, if it was modified.
289    pub backup: Option<PathBuf>,
290    /// Is the override mode active (vs the canonical template)?
291    pub using_override: bool,
292}
293
294/// The full `epicenter setup` orchestration: compute the decision, make the
295/// directory, and write/merge the cargo config.
296/// Normalize a user-provided `--cache-root` value.
297///
298/// - cargo template strings (`{…}`) pass through untouched — they resolve at
299///   build time via cargo, exactly like the default template;
300/// - `~` expands to the home directory;
301/// - relative paths resolve against the current directory;
302/// - absolute paths stay as-is.
303pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
304    let raw = raw.trim();
305    anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
306    if raw.contains('{') && raw.contains('}') {
307        return Ok(PathBuf::from(raw)); // template passthrough
308    }
309    let home = paths::home()?;
310    if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
311        let rest = raw[1..].trim_start_matches(['/', '\\']);
312        return Ok(home.join(rest));
313    }
314    let path = PathBuf::from(raw);
315    if path.is_absolute() {
316        return Ok(path);
317    }
318    let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
319    Ok(cwd.join(path))
320}
321
322/// Priority: CLI root > stored override (`~/.epicenter/config.toml`) > default
323/// template. `--cache-root` and `--use-default` rewrite the stored decision;
324/// without them the stored decision (if any) stays untouched.
325pub fn setup(
326    root_arg: Option<&str>,
327    use_default: bool,
328    with_wrapper: Option<&str>,
329    without_wrapper: bool,
330) -> Result<SetupReport> {
331    // Decision precedence: CLI root > the value already in cargo's config >
332    // the canonical default template. `--cache-root` / `--use-default`
333    // REWRITE the cargo-config decision; without them it stays untouched.
334    // There is no epicenter side-file — `~/.cargo/config.toml` is the state.
335    let build_dir_value: String = if let Some(raw) = root_arg {
336        normalize_root_input(raw)?.to_string_lossy().into_owned()
337    } else if use_default {
338        DEFAULT_BUILD_DIR.to_string()
339    } else {
340        cargo_configured("build-dir")?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
341    };
342
343    validate_config_value(&build_dir_value, "build-dir")?;
344    if let Some(name) = with_wrapper {
345        validate_config_value(name, "rustc-wrapper")?;
346    }
347
348    // The filesystem view must always be RESOLVED (a template is not a path).
349    let root = paths::resolve_template(&build_dir_value)?;
350    crate::util::create_private_dir_all(&root)
351        .with_context(|| format!("creating cache dir {}", root.display()))?;
352
353    // No epicenter side-file: the decision itself is the cargo config below.
354    let cargo_cfg = cargo_config_path()?;
355    let (build_dir_edit, wrapper_edit, backup) = if !cargo_cfg.exists() {
356        if let Some(parent) = cargo_cfg.parent() {
357            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
358        }
359        let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
360        let wrapper_edit = if let Some(name) = with_wrapper {
361            text.push_str(&format!("rustc-wrapper = '{name}'\n"));
362            Some(CargoEdit::Inserted)
363        } else {
364            None
365        };
366        fs::write(&cargo_cfg, text)?;
367        (CargoEdit::Created, wrapper_edit, None)
368    } else {
369        let text = crate::util::read_small_text_follow(&cargo_cfg, 1024 * 1024)
370            .with_context(|| format!("reading {}", cargo_cfg.display()))?;
371        let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
372        let (text, wrapper_edit) = match with_wrapper {
373            Some(name) => {
374                let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
375                (text, Some(edit))
376            }
377            None if without_wrapper => {
378                let (text, edit) = remove_build_key(&text, "rustc-wrapper");
379                (text, Some(edit))
380            }
381            None => (text, None),
382        };
383        let mut backup = None;
384        let changed = build_dir_edit != CargoEdit::AlreadySet
385            || wrapper_edit
386                .as_ref()
387                .is_some_and(|e| *e != CargoEdit::AlreadySet);
388        if changed {
389            backup = Some(backup_file(&cargo_cfg)?);
390            fs::write(&cargo_cfg, text)
391                .with_context(|| format!("writing {}", cargo_cfg.display()))?;
392        }
393        (build_dir_edit, wrapper_edit, backup)
394    };
395
396    Ok(SetupReport {
397        root,
398        build_dir_edit,
399        wrapper_edit,
400        backup,
401        using_override: build_dir_value != DEFAULT_BUILD_DIR,
402    })
403}
404
405/// Copy `path` to `path.epicenter-bak` before modifying it.
406fn backup_file(path: &Path) -> Result<PathBuf> {
407    let mut name = path.file_name().unwrap_or_default().to_os_string();
408    name.push(".epicenter-bak");
409    let base = path.with_file_name(name);
410    // Never write through a pre-existing symlink; pick a fresh name instead.
411    let mut backup = base.clone();
412    let mut suffix = 0u32;
413    while fs::symlink_metadata(&backup).is_ok() {
414        suffix += 1;
415        let mut name = base.file_name().unwrap_or_default().to_os_string();
416        name.push(format!(".{suffix}"));
417        backup = base.with_file_name(name);
418    }
419    // Follow a user's dotfile symlink deliberately, but create the backup
420    // itself with O_EXCL (never write through a planted backup symlink).
421    let source = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
422    crate::util::copy_file_hashed(&source, &backup)
423        .with_context(|| format!("backing up {}", path.display()))?;
424    if let Ok(meta) = fs::metadata(&source) {
425        let _ = fs::set_permissions(&backup, meta.permissions());
426    }
427    Ok(backup)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn replaced_line_keeps_the_rest_of_the_file() {
436        // Regression test for the split_inclusive `Replaced` bug: the kache
437        // line and the [net] table had to survive the replacement.
438        let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
439        let (out, edit) = set_build_key(src, "build-dir", "/cache");
440        assert_eq!(edit, CargoEdit::Replaced);
441        assert!(
442            out.contains("rustc-wrapper = \"kache\""),
443            "tail deleted:\n{out}"
444        );
445        assert!(out.contains("[net]"), "tail deleted:\n{out}");
446        let dir = out.find("build-dir = '/cache'").unwrap();
447        let net = out.find("[net]").unwrap();
448        assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
449    }
450
451    #[test]
452    fn appends_table_without_merging_lines() {
453        // Regression test for the collect::<String> `Appended` bug: joining
454        // without separators fused `[net]` and `foo = 1` into one line.
455        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
456        assert_eq!(edit, CargoEdit::Appended);
457        assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
458        assert!(out.contains("foo = 1"));
459        let net = out.find("[net]").unwrap();
460        let build = out.rfind("[build]").unwrap();
461        assert!(net < build);
462    }
463
464    #[test]
465    fn appends_build_table_when_missing() {
466        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
467        assert_eq!(edit, CargoEdit::Appended);
468        assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
469    }
470
471    #[test]
472    fn inserts_into_existing_build_table() {
473        let (out, edit) = set_build_key(
474            "[build]\nrustflags = []\n\n[net]\nx = 1\n",
475            "build-dir",
476            "/cache",
477        );
478        assert_eq!(edit, CargoEdit::Inserted);
479        // build-dir must land inside [build], i.e. after the header but before [net].
480        let build = out.find("[build]").unwrap();
481        let dir = out.find("build-dir").unwrap();
482        let net = out.find("[net]").unwrap();
483        assert!(build < dir && dir < net, "got:\n{out}");
484    }
485
486    #[test]
487    fn replaces_old_value() {
488        let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
489        assert_eq!(edit, CargoEdit::Replaced);
490        assert!(out.contains("build-dir = '/cache'"));
491        assert!(!out.contains("/old"));
492    }
493
494    #[test]
495    fn already_set_is_a_no_op() {
496        let src = "[build]\nbuild-dir = '/cache'\n";
497        let (out, edit) = set_build_key(src, "build-dir", "/cache");
498        assert_eq!(edit, CargoEdit::AlreadySet);
499        assert_eq!(out, src);
500    }
501
502    #[test]
503    fn removes_wrapper_line() {
504        let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
505        let (out, edit) = remove_build_key(src, "rustc-wrapper");
506        assert_eq!(edit, CargoEdit::Removed);
507        assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
508        assert!(!out.contains("rustc-wrapper"));
509    }
510
511    #[test]
512    fn remove_is_a_no_op_when_absent() {
513        let src = "[build]\nbuild-dir = '/cache'\n";
514        let (out, edit) = remove_build_key(src, "rustc-wrapper");
515        assert_eq!(edit, CargoEdit::AlreadySet);
516        assert_eq!(out, src);
517    }
518
519    #[test]
520    fn reads_key_from_build_table() {
521        assert_eq!(
522            read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
523            Some("/cache".to_string()),
524        );
525        assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
526        // A build-dir under some OTHER table must not be picked up.
527        assert_eq!(
528            read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
529            None
530        );
531    }
532
533    #[test]
534    fn reads_generic_table_keys() {
535        let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
536        assert_eq!(
537            read_table_key(text, "unstable", "fine-grain-locking"),
538            Some("true".to_string())
539        );
540        assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
541        assert_eq!(
542            read_table_key(text, "build", "build-dir"),
543            Some("/x".to_string())
544        );
545        // A key mentioned in a comment elsewhere must not leak.
546        assert_eq!(
547            read_table_key(
548                "[other]\n# fine-grain-locking = true\n",
549                "unstable",
550                "fine-grain-locking"
551            ),
552            None
553        );
554    }
555
556    #[test]
557    fn config_writes_reject_injection() {
558        assert!(validate_config_value("x'\nrustc-wrapper = '/tmp/evil' #", "build-dir").is_err());
559        assert!(validate_config_value("x\r\ny", "build-dir").is_err());
560        assert!(validate_config_value("x\0y", "build-dir").is_err());
561        assert!(validate_config_value("/tmp/cache", "build-dir").is_ok());
562    }
563
564    #[test]
565    fn removal_is_scoped_to_the_build_table() {
566        let src = "[build]\nbuild-dir = '/x'\n\n[target.x]\nrustc-wrapper = 'keep'\n";
567        let (out, edit) = remove_build_key(src, "rustc-wrapper");
568        assert_eq!(edit, CargoEdit::AlreadySet);
569        assert!(out.contains("rustc-wrapper = 'keep'"));
570    }
571
572    #[test]
573    fn comments_inside_quoted_values_are_preserved() {
574        assert_eq!(
575            read_build_key("[build]\nbuild-dir = '/tmp/a#b' # tail\n", "build-dir"),
576            Some("/tmp/a#b".to_string())
577        );
578    }
579
580    #[test]
581    fn cache_root_input_is_normalized() {
582        // Cargo templates pass through untouched.
583        assert_eq!(
584            normalize_root_input("{cargo-cache-home}/custom").unwrap(),
585            PathBuf::from("{cargo-cache-home}/custom"),
586        );
587        // Absolute stays absolute.
588        assert_eq!(
589            normalize_root_input("/tmp/cache").unwrap(),
590            PathBuf::from("/tmp/cache"),
591        );
592        // `~` expands to the home directory.
593        let home = paths::home().unwrap();
594        assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
595        // Relative resolves against the current directory.
596        let cwd = std::env::current_dir().unwrap();
597        assert_eq!(
598            normalize_root_input("cache/build").unwrap(),
599            cwd.join("cache/build"),
600        );
601    }
602}