1use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::paths::{self, DEFAULT_BUILD_DIR};
13
14pub fn cache_root() -> Result<PathBuf> {
19 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
40fn 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
62pub 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
69fn 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
78pub fn cargo_config_path() -> Result<PathBuf> {
81 Ok(paths::cargo_home()?.join("config.toml"))
82}
83
84#[derive(Debug, PartialEq, Eq)]
86pub enum CargoEdit {
87 Created,
89 Inserted,
91 Replaced,
93 Removed,
95 AlreadySet,
97 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
114fn 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 let header = lines
123 .iter()
124 .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
125
126 match header {
127 Some(i) => {
128 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
159fn 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}
187fn read_build_key(text: &str, key: &str) -> Option<String> {
189 read_table_key(text, "build", key)
190}
191
192fn 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
222pub 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
254pub 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
264pub 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
274pub 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 pub root: PathBuf,
284 pub build_dir_edit: CargoEdit,
286 pub wrapper_edit: Option<CargoEdit>,
288 pub backup: Option<PathBuf>,
290 pub using_override: bool,
292}
293
294pub 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)); }
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
322pub fn setup(
326 root_arg: Option<&str>,
327 use_default: bool,
328 with_wrapper: Option<&str>,
329 without_wrapper: bool,
330) -> Result<SetupReport> {
331 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 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 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
405fn 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 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 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 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 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 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 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 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 assert_eq!(
584 normalize_root_input("{cargo-cache-home}/custom").unwrap(),
585 PathBuf::from("{cargo-cache-home}/custom"),
586 );
587 assert_eq!(
589 normalize_root_input("/tmp/cache").unwrap(),
590 PathBuf::from("/tmp/cache"),
591 );
592 let home = paths::home().unwrap();
594 assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
595 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}