Skip to main content

rust_mirror/selection/
sync.rs

1//! One-shot orchestration. Publication, activation and pruning are separate operations.
2use 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}
203/// Detect inconsistencies before payload download. `true` means every requested record
204/// and file already exists in the independently verified previous generation.
205fn 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}
296/// Explicitly discard a managed run's progress and mirror, then resolve a fresh
297/// cohort at the same path. No backup is kept; `previous` must not overlap.
298pub 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            // A failed fresh resolution after force leaves only this lock marker.
312            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        // Validate the full destructive scope before deleting anything. Never follow
325        // links/junctions, and keep the lock file in place throughout restart.
326        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}
371/// Synchronize all configured selections into a new immutable generation. No server
372/// writes, client activation or pruning. Resume requires exact config/previous identity.
373pub 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        // Resume never initializes an unrelated directory, even an empty one.
435        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}