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> {
18 let root = trusted_cache_root()?;
19 crate::util::ensure_windows_user_path(&root, "cache root")?;
20 Ok(root)
21}
22
23pub fn trusted_cache_root() -> Result<PathBuf> {
27 let value = paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
28 paths::resolve_template(&value)
29}
30
31fn validate_config_value(value: &str, what: &str) -> Result<()> {
34 if value.contains(['\n', '\r', '\0', '\'']) {
35 anyhow::bail!("{what} contains a forbidden character (newline, NUL or single quote)");
36 }
37 Ok(())
38}
39
40pub fn cargo_config_path() -> Result<PathBuf> {
43 let cargo_home = paths::cargo_home()?;
44 if cargo_home.is_relative() {
45 anyhow::bail!(
46 "CARGO_HOME is relative ({}) — set an absolute CARGO_HOME",
47 cargo_home.display()
48 );
49 }
50 match fs::symlink_metadata(&cargo_home) {
55 Ok(meta) => {
56 if !meta.is_dir() || meta.file_type().is_symlink() {
57 anyhow::bail!(
58 "CARGO_HOME {} is a symlink or not a directory",
59 cargo_home.display()
60 );
61 }
62 if !crate::util::owned_by_us(&meta) {
63 anyhow::bail!(
64 "CARGO_HOME {} is owned by another user",
65 cargo_home.display()
66 );
67 }
68 #[cfg(unix)]
69 {
70 use std::os::unix::fs::MetadataExt;
71 if meta.mode() & 0o022 != 0 {
72 anyhow::bail!(
73 "CARGO_HOME {} is group/world-writable (mode {:o})",
74 cargo_home.display(),
75 meta.mode() & 0o777
76 );
77 }
78 }
79 }
80 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
81 Err(err) => {
82 return Err(err).with_context(|| format!("inspecting {}", cargo_home.display()));
83 }
84 }
85 let path = cargo_home.join("config.toml");
86 if path.is_relative() {
87 anyhow::bail!(
88 "global cargo config path is relative ({}) — set an absolute CARGO_HOME",
89 path.display()
90 );
91 }
92 crate::util::ensure_windows_user_path(&path, "global cargo config")?;
93 match fs::symlink_metadata(&path) {
94 Ok(meta) => {
95 if meta.file_type().is_symlink() || !meta.is_file() {
96 anyhow::bail!(
97 "global cargo config {} is a symlink or not a regular file",
98 path.display()
99 );
100 }
101 if !crate::util::owned_by_us(&meta) {
102 anyhow::bail!(
103 "global cargo config {} is owned by another user",
104 path.display()
105 );
106 }
107 #[cfg(unix)]
108 {
109 use std::os::unix::fs::MetadataExt;
110 if meta.mode() & 0o022 != 0 {
111 anyhow::bail!(
112 "global cargo config {} is group/world-writable (mode {:o})",
113 path.display(),
114 meta.mode() & 0o777
115 );
116 }
117 }
118 }
119 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
120 Err(err) => {
121 return Err(err).with_context(|| format!("inspecting {}", path.display()));
122 }
123 }
124 Ok(path)
125}
126
127#[derive(Debug, PartialEq, Eq)]
129pub enum CargoEdit {
130 Created,
132 Inserted,
134 Replaced,
136 Removed,
138 AlreadySet,
140 Appended,
142}
143
144impl CargoEdit {
145 pub fn describe(&self) -> &'static str {
146 match self {
147 CargoEdit::Created => "created the file",
148 CargoEdit::Inserted => "added",
149 CargoEdit::Replaced => "updated",
150 CargoEdit::Removed => "removed",
151 CargoEdit::AlreadySet => "already set, unchanged",
152 CargoEdit::Appended => "appended line at the EOF",
153 }
154 }
155}
156
157fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
160 let wanted = format!("{key} = '{value}'");
161 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
162
163 let header = lines
166 .iter()
167 .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
168
169 match header {
170 Some(i) => {
171 let mut j = i + 1;
173 while j < lines.len() {
174 let t = lines[j].trim();
175 if t.starts_with('[') && t.ends_with(']') {
176 break;
177 }
178 if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
179 if t == wanted {
180 return (text.to_string(), CargoEdit::AlreadySet);
181 }
182 lines[j] = wanted;
183 return (lines.join("\n") + "\n", CargoEdit::Replaced);
184 }
185 j += 1;
186 }
187 lines.insert(i + 1, wanted);
188 (lines.join("\n") + "\n", CargoEdit::Inserted)
189 }
190 None => {
191 let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
192 if needs_blank {
193 lines.push(String::new());
194 }
195 lines.push("[build]".to_string());
196 lines.push(wanted);
197 (lines.join("\n") + "\n", CargoEdit::Appended)
198 }
199 }
200}
201
202fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
204 let mut out: Vec<String> = Vec::new();
205 let mut in_build = false;
206 let mut removed = false;
207 for line in text.lines() {
208 let trimmed = line.trim();
209 if trimmed.starts_with('[') && trimmed.ends_with(']') {
210 in_build = trimmed.replace([' ', '\t'], "") == "[build]";
211 out.push(line.to_string());
212 continue;
213 }
214 if in_build
215 && trimmed
216 .split_once('=')
217 .map_or(false, |(candidate, _)| candidate.trim() == key)
218 {
219 removed = true;
220 continue;
221 }
222 out.push(line.to_string());
223 }
224 if removed {
225 (out.join("\n") + "\n", CargoEdit::Removed)
226 } else {
227 (text.to_string(), CargoEdit::AlreadySet)
228 }
229}
230fn read_build_key(text: &str, key: &str) -> Option<String> {
232 read_table_key(text, "build", key)
233}
234
235fn strip_toml_comment(value: &str) -> &str {
237 let mut quote: Option<char> = None;
238 let mut escaped = false;
239 for (index, ch) in value.char_indices() {
240 if escaped {
241 escaped = false;
242 continue;
243 }
244 match quote {
245 Some(q) => {
246 if ch == '\\' && q == '"' {
247 escaped = true;
248 } else if ch == q {
249 quote = None;
250 }
251 }
252 None => {
253 if ch == '#' {
254 return &value[..index];
255 }
256 if ch == '"' || ch == '\'' {
257 quote = Some(ch);
258 }
259 }
260 }
261 }
262 value
263}
264
265pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
270 let header = format!("[{table}]");
271 let dotted = format!("{table}.{key}");
272 let mut in_table = false;
273 let mut at_root = true;
274 for line in text.lines() {
275 let trimmed = line.trim();
276 if trimmed.starts_with('[') && trimmed.ends_with(']') {
277 at_root = false;
278 in_table = trimmed.replace([' ', '\t'], "") == header;
279 continue;
280 }
281 let candidate = trimmed.split_once('=').map(|(k, _)| k.trim());
282 if at_root && candidate == Some(dotted.as_str()) {
284 if let Some((_, value)) = trimmed.split_once('=') {
285 return Some(clean_toml_value(value));
286 }
287 }
288 if !in_table {
289 continue;
290 }
291 if let Some((candidate, value)) = trimmed.split_once('=') {
292 if candidate.trim() != key {
293 continue;
294 }
295 return Some(clean_toml_value(value));
296 }
297 }
298 None
299}
300
301fn clean_toml_value(value: &str) -> String {
302 let value = strip_toml_comment(value).trim();
303 let value = value
304 .strip_prefix('\'')
305 .and_then(|v| v.strip_suffix('\''))
306 .or_else(|| value.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
307 .unwrap_or(value);
308 value.trim().to_string()
309}
310
311pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
313 let path = cargo_config_path()?;
314 if !path.exists() {
315 return Ok(None);
316 }
317 let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
318 Ok(read_table_key(&text, table, key))
319}
320
321pub fn cargo_configured(key: &str) -> Result<Option<String>> {
323 let path = cargo_config_path()?;
324 if !path.exists() {
325 return Ok(None);
326 }
327 let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
328 Ok(read_build_key(&text, key))
329}
330
331pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
334 let text = crate::util::read_small_text(path, 1024 * 1024).ok()?;
335 read_build_key(&text, key)
336}
337
338pub struct SetupReport {
339 pub root: PathBuf,
341 pub build_dir_edit: CargoEdit,
343 pub wrapper_edit: Option<CargoEdit>,
345 pub backup: Option<PathBuf>,
347 pub using_override: bool,
349}
350
351pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
361 let raw = raw.trim();
362 anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
363 if raw.contains('{') && raw.contains('}') {
364 return Ok(PathBuf::from(raw)); }
366 let home = paths::home()?;
367 if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
368 let rest = raw[1..].trim_start_matches(['/', '\\']);
369 return Ok(home.join(rest));
370 }
371 let path = PathBuf::from(raw);
372 if path.is_absolute() {
373 return Ok(path);
374 }
375 let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
376 Ok(cwd.join(path))
377}
378
379pub fn setup(
383 root_arg: Option<&str>,
384 use_default: bool,
385 with_wrapper: Option<&str>,
386 without_wrapper: bool,
387) -> Result<SetupReport> {
388 let build_dir_value: String = if let Some(raw) = root_arg {
393 normalize_root_input(raw)?.to_string_lossy().into_owned()
394 } else if use_default {
395 DEFAULT_BUILD_DIR.to_string()
396 } else {
397 paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
398 };
399
400 validate_config_value(&build_dir_value, "build-dir")?;
401 if let Some(name) = with_wrapper {
402 validate_config_value(name, "rustc-wrapper")?;
403 }
404
405 let root = paths::resolve_template(&build_dir_value)?;
407 crate::util::ensure_windows_user_path(&root, "cache root")?;
408 crate::util::create_private_dir_all(&root)
409 .with_context(|| format!("creating cache dir {}", root.display()))?;
410
411 let cargo_cfg = cargo_config_path()?;
413 let (build_dir_edit, wrapper_edit, backup) = if !cargo_cfg.exists() {
414 if let Some(parent) = cargo_cfg.parent() {
415 crate::util::create_private_dir_all(parent)
416 .with_context(|| format!("creating {}", parent.display()))?;
417 }
418 let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
419 let wrapper_edit = if let Some(name) = with_wrapper {
420 text.push_str(&format!("rustc-wrapper = '{name}'\n"));
421 Some(CargoEdit::Inserted)
422 } else {
423 None
424 };
425 crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
426 (CargoEdit::Created, wrapper_edit, None)
427 } else {
428 let text = crate::util::read_trusted_config(&cargo_cfg, 1024 * 1024)
429 .with_context(|| format!("reading {}", cargo_cfg.display()))?;
430 let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
431 let (text, wrapper_edit) = match with_wrapper {
432 Some(name) => {
433 let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
434 (text, Some(edit))
435 }
436 None if without_wrapper => {
437 let (text, edit) = remove_build_key(&text, "rustc-wrapper");
438 (text, Some(edit))
439 }
440 None => (text, None),
441 };
442 let mut backup = None;
443 let changed = build_dir_edit != CargoEdit::AlreadySet
444 || wrapper_edit
445 .as_ref()
446 .is_some_and(|e| *e != CargoEdit::AlreadySet);
447 if changed {
448 backup = Some(backup_file(&cargo_cfg)?);
449 crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
450 }
451 (build_dir_edit, wrapper_edit, backup)
452 };
453
454 Ok(SetupReport {
455 root,
456 build_dir_edit,
457 wrapper_edit,
458 backup,
459 using_override: build_dir_value != DEFAULT_BUILD_DIR,
460 })
461}
462
463fn backup_file(path: &Path) -> Result<PathBuf> {
465 let mut name = path.file_name().unwrap_or_default().to_os_string();
466 name.push(".epicenter-bak");
467 let base = path.with_file_name(name);
468 let mut backup = base.clone();
470 let mut suffix = 0u32;
471 while fs::symlink_metadata(&backup).is_ok() {
472 suffix += 1;
473 let mut name = base.file_name().unwrap_or_default().to_os_string();
474 name.push(format!(".{suffix}"));
475 backup = base.with_file_name(name);
476 }
477 let source = path.to_path_buf();
480 crate::util::copy_file_hashed(&source, &backup)
481 .with_context(|| format!("backing up {}", path.display()))?;
482 if let Ok(meta) = fs::metadata(&source) {
483 let _ = fs::set_permissions(&backup, meta.permissions());
484 }
485 Ok(backup)
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn replaced_line_keeps_the_rest_of_the_file() {
494 let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
497 let (out, edit) = set_build_key(src, "build-dir", "/cache");
498 assert_eq!(edit, CargoEdit::Replaced);
499 assert!(
500 out.contains("rustc-wrapper = \"kache\""),
501 "tail deleted:\n{out}"
502 );
503 assert!(out.contains("[net]"), "tail deleted:\n{out}");
504 let dir = out.find("build-dir = '/cache'").unwrap();
505 let net = out.find("[net]").unwrap();
506 assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
507 }
508
509 #[test]
510 fn appends_table_without_merging_lines() {
511 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
514 assert_eq!(edit, CargoEdit::Appended);
515 assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
516 assert!(out.contains("foo = 1"));
517 let net = out.find("[net]").unwrap();
518 let build = out.rfind("[build]").unwrap();
519 assert!(net < build);
520 }
521
522 #[test]
523 fn appends_build_table_when_missing() {
524 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
525 assert_eq!(edit, CargoEdit::Appended);
526 assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
527 }
528
529 #[test]
530 fn inserts_into_existing_build_table() {
531 let (out, edit) = set_build_key(
532 "[build]\nrustflags = []\n\n[net]\nx = 1\n",
533 "build-dir",
534 "/cache",
535 );
536 assert_eq!(edit, CargoEdit::Inserted);
537 let build = out.find("[build]").unwrap();
539 let dir = out.find("build-dir").unwrap();
540 let net = out.find("[net]").unwrap();
541 assert!(build < dir && dir < net, "got:\n{out}");
542 }
543
544 #[test]
545 fn replaces_old_value() {
546 let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
547 assert_eq!(edit, CargoEdit::Replaced);
548 assert!(out.contains("build-dir = '/cache'"));
549 assert!(!out.contains("/old"));
550 }
551
552 #[test]
553 fn already_set_is_a_no_op() {
554 let src = "[build]\nbuild-dir = '/cache'\n";
555 let (out, edit) = set_build_key(src, "build-dir", "/cache");
556 assert_eq!(edit, CargoEdit::AlreadySet);
557 assert_eq!(out, src);
558 }
559
560 #[test]
561 fn removes_wrapper_line() {
562 let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
563 let (out, edit) = remove_build_key(src, "rustc-wrapper");
564 assert_eq!(edit, CargoEdit::Removed);
565 assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
566 assert!(!out.contains("rustc-wrapper"));
567 }
568
569 #[test]
570 fn remove_is_a_no_op_when_absent() {
571 let src = "[build]\nbuild-dir = '/cache'\n";
572 let (out, edit) = remove_build_key(src, "rustc-wrapper");
573 assert_eq!(edit, CargoEdit::AlreadySet);
574 assert_eq!(out, src);
575 }
576
577 #[test]
578 fn reads_key_from_build_table() {
579 assert_eq!(
580 read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
581 Some("/cache".to_string()),
582 );
583 assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
584 assert_eq!(
586 read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
587 None
588 );
589 }
590
591 #[test]
592 fn reads_generic_table_keys() {
593 let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
594 assert_eq!(
595 read_table_key(text, "unstable", "fine-grain-locking"),
596 Some("true".to_string())
597 );
598 assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
599 assert_eq!(
600 read_table_key(text, "build", "build-dir"),
601 Some("/x".to_string())
602 );
603 assert_eq!(
605 read_table_key(
606 "[other]\n# fine-grain-locking = true\n",
607 "unstable",
608 "fine-grain-locking"
609 ),
610 None
611 );
612 }
613
614 #[test]
615 fn config_writes_reject_injection() {
616 assert!(validate_config_value("x'\nrustc-wrapper = '/tmp/evil' #", "build-dir").is_err());
617 assert!(validate_config_value("x\r\ny", "build-dir").is_err());
618 assert!(validate_config_value("x\0y", "build-dir").is_err());
619 assert!(validate_config_value("/tmp/cache", "build-dir").is_ok());
620 }
621
622 #[test]
623 fn removal_is_scoped_to_the_build_table() {
624 let src = "[build]\nbuild-dir = '/x'\n\n[target.x]\nrustc-wrapper = 'keep'\n";
625 let (out, edit) = remove_build_key(src, "rustc-wrapper");
626 assert_eq!(edit, CargoEdit::AlreadySet);
627 assert!(out.contains("rustc-wrapper = 'keep'"));
628 }
629
630 #[test]
631 fn dotted_build_dir_is_detected() {
632 assert_eq!(
633 read_build_key("build.build-dir = '/tmp/dotted'\n", "build-dir"),
634 Some("/tmp/dotted".to_string())
635 );
636 }
637
638 #[test]
639 fn comments_inside_quoted_values_are_preserved() {
640 assert_eq!(
641 read_build_key("[build]\nbuild-dir = '/tmp/a#b' # tail\n", "build-dir"),
642 Some("/tmp/a#b".to_string())
643 );
644 }
645
646 #[test]
647 fn cache_root_input_is_normalized() {
648 assert_eq!(
650 normalize_root_input("{cargo-cache-home}/custom").unwrap(),
651 PathBuf::from("{cargo-cache-home}/custom"),
652 );
653 let absolute = if cfg!(windows) {
655 r"C:\cache"
656 } else {
657 "/tmp/cache"
658 };
659 assert_eq!(
660 normalize_root_input(absolute).unwrap(),
661 PathBuf::from(absolute),
662 );
663 let home = paths::home().unwrap();
665 assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
666 let cwd = std::env::current_dir().unwrap();
668 assert_eq!(
669 normalize_root_input("cache/build").unwrap(),
670 cwd.join("cache/build"),
671 );
672 }
673}