1use super::{
3 collection::{self, CollectionSnapshot, Input, ToolchainRecord},
4 Config, Plan, ResolvedPlan, Result,
5};
6use crate::{sha256, Source, Transfer, ARTIFACT_LIMIT};
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 fs::{self, File},
11 io::{self, Seek, SeekFrom, Write},
12 path::{Path, PathBuf},
13 sync::Mutex,
14};
15#[derive(Default, Debug, Serialize)]
16pub struct Report {
17 pub status: String,
18 pub selections: usize,
19 pub frozen: Vec<SelectionStatus>,
20 pub metadata_requests: u64,
21 pub local_payload_bytes: u64,
22 pub upstream_payload_bytes: u64,
23 pub upstream_attempts: u64,
24 pub files: usize,
25 pub recorded_bytes: u64,
26 pub resumed: bool,
27}
28#[derive(Debug, Serialize)]
29pub struct SelectionStatus {
30 pub name: String,
31 pub requested: String,
32 pub pinned_toolchain: String,
33 pub release: String,
34 pub date: String,
35}
36#[derive(Debug)]
37pub struct SyncResult {
38 pub mirror: PathBuf,
39 pub report: Report,
40}
41#[derive(Deserialize, Serialize)]
42#[serde(deny_unknown_fields)]
43struct State {
44 schema: u32,
45 config_sha256: String,
46 previous_sha256: Option<String>,
47 plans: Vec<String>,
48}
49#[derive(Default)]
50struct Counts {
51 metadata: u64,
52 local: u64,
53 upstream: u64,
54 attempts: u64,
55}
56struct MetadataSource<'a, S> {
57 inner: &'a S,
58 counts: &'a Mutex<Counts>,
59}
60impl<S: Source> Source for MetadataSource<'_, S> {
61 fn fetch(&self, url: &str, out: &mut dyn Write, limit: u64) -> Result<()> {
62 self.counts.lock().unwrap().metadata += 1;
63 self.inner.fetch(url, out, limit)
64 }
65}
66struct PayloadSource<'a, S> {
67 inner: &'a S,
68 known: &'a BTreeMap<String, PathBuf>,
69 expected: &'a BTreeMap<String, String>,
70 counts: &'a Mutex<Counts>,
71}
72struct Counter<'a> {
73 out: &'a mut dyn Write,
74 counts: &'a Mutex<Counts>,
75 local: bool,
76}
77impl Write for Counter<'_> {
78 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
79 let n = self.out.write(buf)?;
80 let mut c = self.counts.lock().unwrap();
81 if self.local {
82 c.local += n as u64;
83 } else {
84 c.upstream += n as u64;
85 }
86 Ok(n)
87 }
88 fn flush(&mut self) -> io::Result<()> {
89 self.out.flush()
90 }
91}
92impl<S: Source> Source for PayloadSource<'_, S> {
93 fn fetch(&self, url: &str, out: &mut dyn Write, limit: u64) -> Result<()> {
94 match self.fetch_from(url, out, 0, limit)? {
95 Transfer::Complete => Ok(()),
96 Transfer::Restart => Err("payload source requested unexpected restart".into()),
97 }
98 }
99 fn fetch_from(
100 &self,
101 url: &str,
102 out: &mut dyn Write,
103 offset: u64,
104 limit: u64,
105 ) -> Result<Transfer> {
106 let hash = self
107 .expected
108 .get(url)
109 .ok_or("unplanned sync payload request")?;
110 if let Some(path) = self.known.get(hash) {
111 let (n, actual) = crate::resume::hash_file(path, ARTIFACT_LIMIT)?;
112 if &actual != hash {
113 return Err("verified local input changed during sync".into());
114 }
115 if offset > n {
116 return Ok(Transfer::Restart);
117 }
118 if n - offset > limit {
119 return Err("local reuse exceeds remaining workspace budget".into());
120 }
121 let mut file = File::open(path)?;
122 file.seek(SeekFrom::Start(offset))?;
123 let mut writer = Counter {
124 out,
125 counts: self.counts,
126 local: true,
127 };
128 crate::copy_bounded(file, &mut writer, limit)?;
129 Ok(Transfer::Complete)
130 } else {
131 self.counts.lock().unwrap().attempts += 1;
132 let mut writer = Counter {
133 out,
134 counts: self.counts,
135 local: false,
136 };
137 self.inner.fetch_from(url, &mut writer, offset, limit)
138 }
139 }
140}
141fn stage(message: &str) {
142 let _ = writeln!(io::stderr().lock(), "sync: {message}");
143}
144fn real_dir(path: &Path) -> Result<()> {
145 let m = fs::symlink_metadata(path)?;
146 if !m.is_dir() || m.file_type().is_symlink() {
147 return Err("sync directory must not be a link".into());
148 }
149 Ok(())
150}
151fn regular(path: &Path, limit: u64) -> Result<()> {
152 let m = fs::symlink_metadata(path)?;
153 if !m.is_file() || m.file_type().is_symlink() || m.len() > limit {
154 return Err("invalid sync state file".into());
155 }
156 Ok(())
157}
158fn exists(path: &Path) -> Result<bool> {
159 match fs::symlink_metadata(path) {
160 Ok(_) => Ok(true),
161 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
162 Err(e) => Err(e.into()),
163 }
164}
165fn children(root: &Path, names: &BTreeSet<PathBuf>) -> Result<()> {
166 real_dir(root)?;
167 for entry in fs::read_dir(root)? {
168 let e = entry?;
169 if !names.contains(&PathBuf::from(e.file_name())) {
170 return Err("unexpected sync workspace entry".into());
171 }
172 if e.file_type()?.is_symlink() {
173 return Err("sync workspace links are refused".into());
174 }
175 }
176 Ok(())
177}
178fn named(names: &[&str]) -> BTreeSet<PathBuf> {
179 names.iter().map(PathBuf::from).collect()
180}
181fn plan_hash(p: &Plan) -> Result<String> {
182 Ok(sha256(toml::to_string_pretty(p)?.as_bytes()))
183}
184fn record(p: &Plan) -> ToolchainRecord {
185 ToolchainRecord {
186 selection: p.selection.clone(),
187 release: p.resolved_release.clone(),
188 date: p.date.clone(),
189 pinned_toolchain: p.pinned_toolchain.clone(),
190 }
191}
192fn read_inventory(path: &Path) -> Result<CollectionSnapshot> {
193 Ok(toml::from_str(std::str::from_utf8(&super::read_file(
194 &path.join("snapshot.toml"),
195 1024 * 1024,
196 )?)?)?)
197}
198fn checked_total(input: &Input) -> Result<u64> {
199 Ok(input.files.values().try_fold(0u64, |n, e| {
200 n.checked_add(e.bytes).ok_or("sync byte count overflow")
201 })?)
202}
203fn preflight(plans: &[ResolvedPlan], old: Option<&Input>, budget: u64) -> Result<bool> {
206 let mut desired = BTreeMap::new();
207 let mut records = old.map(|i| i.records.clone()).unwrap_or_default();
208 let mut unchanged = old.is_some();
209 for resolved in plans {
210 let p = &resolved.plan;
211 let r = record(p);
212 if !old.is_some_and(|i| i.records.contains(&r)) {
213 unchanged = false;
214 }
215 if !records.contains(&r) {
216 records.push(r);
217 }
218 let entries = p
219 .artifacts
220 .iter()
221 .map(|a| (a.path.clone(), a.sha256.clone()))
222 .chain(
223 super::snapshot_metadata(resolved)?
224 .iter()
225 .map(|(p, b)| (p.clone(), sha256(b))),
226 )
227 .collect::<Vec<_>>();
228 for (path, hash) in entries {
229 if desired.get(&path).is_some_and(|h| h != &hash) {
230 return Err("channel changed between resolutions or selections conflict; resolve a fresh coherent run".into());
231 }
232 if let Some(prior) = old.and_then(|i| i.files.get(&path)) {
233 if prior.sha256 != hash {
234 unchanged = false;
235 if !collection::rolling(&path) {
236 return Err(format!("immutable previous path conflict: {path}").into());
237 }
238 }
239 } else {
240 unchanged = false;
241 }
242 desired.insert(path, hash);
243 }
244 }
245 let mut paths: BTreeSet<_> = old
246 .map(|i| i.files.keys().cloned().collect())
247 .unwrap_or_default();
248 paths.extend(desired.keys().cloned());
249 if paths.len() > 4096 || records.len() > 256 {
250 return Err("sync generation file/record limit exceeded".into());
251 }
252 if let Some(old) = old {
253 if checked_total(old)? > budget {
254 return Err(
255 "previous generation exceeds configured budget; sync never prunes it".into(),
256 );
257 }
258 }
259 Ok(unchanged)
260}
261fn result(
262 status: &str,
263 mirror: PathBuf,
264 plans: &[ResolvedPlan],
265 counts: &Mutex<Counts>,
266 files: usize,
267 bytes: u64,
268 resumed: bool,
269) -> SyncResult {
270 let c = counts.lock().unwrap();
271 SyncResult {
272 mirror,
273 report: Report {
274 status: status.into(),
275 selections: plans.len(),
276 frozen: plans
277 .iter()
278 .map(|p| SelectionStatus {
279 name: p.plan.selection.name.clone(),
280 requested: p.plan.selection.toolchain.clone(),
281 pinned_toolchain: p.plan.pinned_toolchain.clone(),
282 release: p.plan.resolved_release.clone(),
283 date: p.plan.date.clone(),
284 })
285 .collect(),
286 metadata_requests: c.metadata,
287 local_payload_bytes: c.local,
288 upstream_payload_bytes: c.upstream,
289 upstream_attempts: c.attempts,
290 files,
291 recorded_bytes: bytes,
292 resumed,
293 },
294 }
295}
296pub fn run_forced(
299 config: &Config,
300 previous: Option<&Path>,
301 output: &Path,
302 source: &impl Source,
303) -> Result<SyncResult> {
304 config.validate()?;
305 if exists(output)? {
306 real_dir(output)?;
307 children(output, &named(&[".sync.lock", ".sync", "mirror"]))?;
308 if exists(&output.join(".sync"))? {
309 real_dir(&output.join(".sync"))?;
310 } else {
311 children(output, &named(&[".sync.lock"]))?;
313 }
314 regular(&output.join(".sync.lock"), 0)?;
315 let target = output.canonicalize()?;
316 if let Some(previous) = previous {
317 let prior = previous.canonicalize()?;
318 if prior.starts_with(&target) || target.starts_with(&prior) {
319 return Err("force output and previous mirror must not overlap".into());
320 }
321 collection::read_input(previous)?;
322 }
323 let guard = lock(output)?;
324 let mut entries = 0;
327 for name in ["mirror", ".sync"] {
328 let path = target.join(name);
329 if exists(&path)? {
330 validate_reset_tree(&path, &target, 0, &mut entries)?;
331 }
332 }
333 stage("--force: discarding existing mirror and progress; no backup");
334 for name in ["mirror", ".sync"] {
335 let path = target.join(name);
336 if exists(&path)? {
337 fs::remove_dir_all(path)?;
338 }
339 }
340 return run_inner(config, previous, output, false, source, Some(guard));
341 }
342 run(config, previous, output, false, source)
343}
344fn validate_reset_tree(path: &Path, root: &Path, depth: usize, count: &mut usize) -> Result<()> {
345 *count += 1;
346 if depth > 64 || *count > 300_000 {
347 return Err("force reset tree exceeds validation budget".into());
348 }
349 let metadata = fs::symlink_metadata(path)?;
350 #[cfg(windows)]
351 {
352 use std::os::windows::fs::MetadataExt;
353 if metadata.file_attributes() & 0x400 != 0 {
354 return Err("force refuses reparse points".into());
355 }
356 }
357 if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
358 return Err("force refuses links and special files".into());
359 }
360 let canonical = path.canonicalize()?;
361 if canonical == root || !canonical.starts_with(root) {
362 return Err("force reset path escapes the output directory".into());
363 }
364 if metadata.is_dir() {
365 for entry in fs::read_dir(path)? {
366 validate_reset_tree(&entry?.path(), root, depth + 1, count)?;
367 }
368 }
369 Ok(())
370}
371pub fn run(
374 config: &Config,
375 previous: Option<&Path>,
376 output: &Path,
377 resume: bool,
378 source: &impl Source,
379) -> Result<SyncResult> {
380 run_inner(config, previous, output, resume, source, None)
381}
382fn run_inner(
383 config: &Config,
384 previous: Option<&Path>,
385 output: &Path,
386 resume: bool,
387 source: &impl Source,
388 forced_guard: Option<File>,
389) -> Result<SyncResult> {
390 let forced = forced_guard.is_some();
391 config.validate()?;
392 if !resume && !forced && exists(output)? {
393 return Err("sync output already exists; use --resume with unchanged configuration, or --force to discard it and start fresh".into());
394 }
395 let parent = output
396 .parent()
397 .filter(|p| !p.as_os_str().is_empty())
398 .unwrap_or_else(|| Path::new("."));
399 let target = parent.canonicalize()?.join(
400 output
401 .file_name()
402 .ok_or("sync needs a new output directory")?,
403 );
404 if let Some(previous) = previous {
405 if target.starts_with(previous.canonicalize()?) {
406 return Err("sync output must not be inside the previous mirror".into());
407 }
408 }
409 let counts = Mutex::new(Counts::default());
410 stage(if previous.is_some() {
411 "verify previous generation"
412 } else {
413 "start new mirror"
414 });
415 let old = previous.map(collection::read_input).transpose()?;
416 if old
417 .as_ref()
418 .is_some_and(|i| i.rustup != config.rustup_version)
419 {
420 return Err("previous and configured Rustup bootstrap versions differ".into());
421 }
422 let config_hash = sha256(toml::to_string_pretty(config)?.as_bytes());
423 let previous_hash = old.as_ref().map(|i| i.identity.clone());
424 let root = output.join(".sync");
425 let plan_root = root.join("plans");
426 let snapshot_root = root.join("snapshots");
427 let allowed_indices: BTreeSet<_> = (0..config.selections.len())
428 .map(|n| PathBuf::from(n.to_string()))
429 .collect();
430 let mut guard = forced_guard;
431 let resolved = if resume {
432 real_dir(output)?;
433 children(output, &named(&[".sync.lock", ".sync", "mirror"]))?;
434 real_dir(&root)?;
436 regular(&root.join("state.toml"), 64 * 1024)?;
437 regular(&output.join(".sync.lock"), 0)?;
438 let lock = lock(output)?;
439 guard = Some(lock);
440 children(
441 &root,
442 &named(&[
443 "state.toml",
444 "plans",
445 "snapshots",
446 "tree",
447 "report.toml",
448 "report.tmp",
449 ]),
450 )?;
451 regular(&root.join("state.toml"), 64 * 1024)?;
452 let state: State = toml::from_str(&fs::read_to_string(root.join("state.toml"))?)?;
453 if state.schema != 1
454 || state.config_sha256 != config_hash
455 || state.previous_sha256 != previous_hash
456 || state.plans.len() != config.selections.len()
457 {
458 return Err("sync resume identity/config/previous mismatch".into());
459 }
460 children(&plan_root, &allowed_indices)?;
461 let mut plans = Vec::new();
462 for (index, selected) in config.selections.iter().enumerate() {
463 let p = super::load_plan(&plan_root.join(index.to_string()))?;
464 if p.plan.selection != *selected
465 || p.plan.rustup_version != config.rustup_version
466 || p.plan.max_download_bytes != config.max_download_bytes
467 || plan_hash(&p.plan)? != state.plans[index]
468 {
469 return Err("saved sync plan differs from initialized run".into());
470 }
471 plans.push(p);
472 }
473 plans
474 } else {
475 let mut plans = Vec::new();
476 let metadata = MetadataSource {
477 inner: source,
478 counts: &counts,
479 };
480 for (index, selected) in config.selections.iter().enumerate() {
481 stage(&format!(
482 "resolve {}/{}: {} ({})",
483 index + 1,
484 config.selections.len(),
485 selected.name,
486 selected.toolchain
487 ));
488 plans.push(super::resolve(config, &selected.name, &metadata)?);
489 }
490 plans
491 };
492 let unchanged = preflight(&resolved, old.as_ref(), config.max_download_bytes)?;
493 if unchanged && !resume && !forced {
494 if exists(output)? {
495 return Err("sync output appeared during resolution".into());
496 }
497 let old = old.as_ref().unwrap();
498 stage("unchanged; previous generation is verified, no payload downloads");
499 return Ok(result(
500 "unchanged",
501 previous.unwrap().to_path_buf(),
502 &resolved,
503 &counts,
504 old.files.len(),
505 checked_total(old)?,
506 false,
507 ));
508 }
509 if !resume {
510 if !forced {
511 fs::create_dir(output)?;
512 guard = Some(lock(output)?);
513 } else {
514 children(output, &named(&[".sync.lock"]))?;
515 }
516 fs::create_dir(&root)?;
517 fs::create_dir(&plan_root)?;
518 fs::create_dir(&snapshot_root)?;
519 for (index, p) in resolved.iter().enumerate() {
520 p.save(&plan_root.join(index.to_string()))?;
521 }
522 let state = State {
523 schema: 1,
524 config_sha256: config_hash,
525 previous_sha256: previous_hash,
526 plans: resolved
527 .iter()
528 .map(|r| plan_hash(&r.plan))
529 .collect::<Result<_>>()?,
530 };
531 super::write_new(
532 &root.join("state.toml"),
533 toml::to_string_pretty(&state)?.as_bytes(),
534 )?;
535 }
536 let _guard = guard.ok_or("sync lock not acquired")?;
537 children(&snapshot_root, &allowed_indices)?;
538 if exists(&root.join("report.toml"))? {
539 regular(&root.join("report.toml"), 64 * 1024)?;
540 }
541 let expected: BTreeMap<_, _> = resolved
542 .iter()
543 .flat_map(|r| {
544 r.plan
545 .artifacts
546 .iter()
547 .map(|a| (format!("{}{}", crate::ORIGIN, a.path), a.sha256.clone()))
548 })
549 .collect();
550 let mut known: BTreeMap<_, _> = old
551 .as_ref()
552 .map(|i| {
553 i.files
554 .values()
555 .map(|e| (e.sha256.clone(), e.source.clone()))
556 .collect()
557 })
558 .unwrap_or_default();
559 let mut inputs = Vec::new();
560 let mut remaining = config.max_download_bytes;
561 for (index, p) in resolved.iter().enumerate() {
562 stage(&format!(
563 "fetch {}/{}: {} -> {}",
564 index + 1,
565 resolved.len(),
566 p.plan.selection.name,
567 p.plan.pinned_toolchain
568 ));
569 let out = snapshot_root.join(index.to_string());
570 let payload = PayloadSource {
571 inner: source,
572 known: &known,
573 expected: &expected,
574 counts: &counts,
575 };
576 let mirror = super::fetch_limited(
577 &plan_root.join(index.to_string()),
578 &out,
579 &payload,
580 exists(&out)?,
581 remaining,
582 )?;
583 let input = collection::read_input(&mirror)?;
584 let bytes = checked_total(&input)?;
585 remaining = remaining
586 .checked_sub(bytes)
587 .ok_or("selected working snapshots exceed configured byte budget")?;
588 for e in input.files.values() {
589 known.insert(e.sha256.clone(), e.source.clone());
590 }
591 inputs.push(mirror);
592 }
593 stage("verify and assemble generation");
594 let (inventory, files) = collection::prepare(&inputs, previous, config.max_download_bytes)?;
595 let completed = output.join("mirror");
596 let status = if exists(&completed)? {
597 collection::read_input(&completed)?;
598 if read_inventory(&completed)? != inventory {
599 return Err(
600 "completed sync generation differs from frozen run; refusing overwrite".into(),
601 );
602 }
603 "validated"
604 } else {
605 let tree = root.join("tree");
606 let expected: BTreeSet<_> = files
607 .keys()
608 .map(PathBuf::from)
609 .chain([PathBuf::from("snapshot.toml")])
610 .collect();
611 if exists(&tree)? {
612 crate::resume::validate_tree(&tree, &expected)?;
613 fs::remove_dir_all(&tree)?;
614 }
615 fs::create_dir(&tree)?;
616 collection::write_prepared(&inventory, &files, &tree)?;
617 if exists(&completed)? {
618 return Err("completed generation appeared during sync".into());
619 }
620 fs::rename(&tree, &completed)?;
621 "created"
622 };
623 let bytes = inventory.files.iter().map(|f| f.bytes).sum();
624 let result = result(
625 status,
626 completed,
627 &resolved,
628 &counts,
629 inventory.files.len(),
630 bytes,
631 resume,
632 );
633 let report = root.join("report.toml");
634 let temporary = root.join("report.tmp");
635 if exists(&temporary)? {
636 regular(&temporary, 64 * 1024)?;
637 fs::remove_file(&temporary)?;
638 }
639 super::write_new(
640 &temporary,
641 toml::to_string_pretty(&result.report)?.as_bytes(),
642 )?;
643 fs::rename(temporary, report)?;
644 stage(&format!(
645 "{}; {} files, {} upstream payload bytes, {} local payload bytes",
646 result.report.status,
647 result.report.files,
648 result.report.upstream_payload_bytes,
649 result.report.local_payload_bytes
650 ));
651 Ok(result)
652}
653fn lock(output: &Path) -> Result<File> {
654 let p = output.join(".sync.lock");
655 if exists(&p)? {
656 regular(&p, 0)?;
657 }
658 let f = File::options()
659 .read(true)
660 .write(true)
661 .create(true)
662 .truncate(false)
663 .open(p)?;
664 f.try_lock()
665 .map_err(|_| "another sync owns this workspace, or file locking is unavailable")?;
666 Ok(f)
667}