Skip to main content

rust_mirror/
selection.rs

1//! Saved selections and metadata-only, replayable download plans.
2pub mod collection;
3pub mod rustup;
4pub mod sync;
5use crate::{
6    bytes, checksum, safe_path, sha256, valid_hash, version, Manifest, Result, SnapshotFile,
7    Source, ARTIFACT_LIMIT, METADATA_LIMIT, ORIGIN,
8};
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fs::{self, File},
13    io::{Read, Write},
14    path::{Path, PathBuf},
15};
16const SMALL_LIMIT: u64 = 1024 * 1024;
17const MAX_FILES: usize = 1024;
18fn default_profile() -> String {
19    "default".into()
20}
21fn default_budget() -> u64 {
22    10 * 1024 * 1024 * 1024
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(deny_unknown_fields)]
27pub struct Config {
28    pub schema: u32,
29    pub rustup_version: String,
30    #[serde(default = "default_budget")]
31    pub max_download_bytes: u64,
32    pub selections: Vec<Selection>,
33}
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(deny_unknown_fields)]
36pub struct Selection {
37    pub name: String,
38    pub toolchain: String,
39    pub hosts: Vec<String>,
40    #[serde(default = "default_profile")]
41    pub profile: String,
42    #[serde(default)]
43    pub components: Vec<String>,
44    #[serde(default)]
45    pub targets: Vec<String>,
46}
47fn atom(s: &str) -> Result<()> {
48    if s.is_empty()
49        || s.len() > 128
50        || s == "."
51        || s == ".."
52        || !s
53            .bytes()
54            .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c))
55    {
56        return Err(format!("invalid selection identifier: {s}").into());
57    }
58    Ok(())
59}
60fn unique(values: &[String], limit: usize) -> Result<()> {
61    if values.len() > limit {
62        return Err("selection list exceeds budget".into());
63    }
64    let mut seen = BTreeSet::new();
65    for value in values {
66        atom(value)?;
67        if !seen.insert(value) {
68            return Err(format!("duplicate selection: {value}").into());
69        }
70    }
71    Ok(())
72}
73fn valid_date(s: &str) -> bool {
74    let p: Vec<_> = s.split('-').collect();
75    if p.len() != 3
76        || p[0].len() != 4
77        || p[1].len() != 2
78        || p[2].len() != 2
79        || !p.iter().all(|s| s.bytes().all(|c| c.is_ascii_digit()))
80    {
81        return false;
82    }
83    let (Ok(y), Ok(m), Ok(d)) = (
84        p[0].parse::<u32>(),
85        p[1].parse::<u32>(),
86        p[2].parse::<u32>(),
87    ) else {
88        return false;
89    };
90    let days = match m {
91        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
92        4 | 6 | 9 | 11 => 30,
93        2 => {
94            if y % 400 == 0 || (y % 4 == 0 && y % 100 != 0) {
95                29
96            } else {
97                28
98            }
99        }
100        _ => 0,
101    };
102    y >= 2010 && d > 0 && d <= days
103}
104#[derive(Debug)]
105struct Toolchain {
106    channel: String,
107    date: Option<String>,
108}
109impl Toolchain {
110    fn parse(value: &str) -> Result<Self> {
111        for channel in ["stable", "beta", "nightly"] {
112            if value == channel {
113                return Ok(Self {
114                    channel: channel.into(),
115                    date: None,
116                });
117            }
118            if let Some(date) = value.strip_prefix(&format!("{channel}-")) {
119                if !valid_date(date) {
120                    return Err("expected a calendar date YYYY-MM-DD".into());
121                }
122                return Ok(Self {
123                    channel: channel.into(),
124                    date: Some(date.into()),
125                });
126            }
127        }
128        version(value)?;
129        Ok(Self {
130            channel: value.into(),
131            date: None,
132        })
133    }
134    fn manifest_path(&self) -> String {
135        match &self.date {
136            Some(d) => format!("dist/{d}/channel-rust-{}.toml", self.channel),
137            None => format!("dist/channel-rust-{}.toml", self.channel),
138        }
139    }
140    fn pinned(&self, date: &str) -> String {
141        if ["stable", "beta", "nightly"].contains(&self.channel.as_str()) {
142            format!("{}-{date}", self.channel)
143        } else {
144            self.channel.clone()
145        }
146    }
147}
148impl Selection {
149    pub fn validate(&self) -> Result<()> {
150        if self.name.trim().is_empty()
151            || self.name.len() > 80
152            || self.name.chars().any(char::is_control)
153        {
154            return Err("invalid selection name".into());
155        }
156        Toolchain::parse(&self.toolchain)?;
157        if self.hosts.is_empty() {
158            return Err("select at least one explicit host platform".into());
159        }
160        unique(&self.hosts, 16)?;
161        unique(&self.components, 64)?;
162        unique(&self.targets, 64)?;
163        if !["minimal", "default"].contains(&self.profile.as_str()) {
164            return Err("use profile minimal or default plus explicit components; complete is unreliable and is not supported".into());
165        }
166        Ok(())
167    }
168}
169impl Config {
170    pub fn parse(text: &str) -> Result<Self> {
171        let c: Self = toml::from_str(text)?;
172        c.validate()?;
173        Ok(c)
174    }
175    pub fn read(path: &Path) -> Result<Self> {
176        Self::parse(std::str::from_utf8(&read_file(path, SMALL_LIMIT)?)?)
177    }
178    pub fn validate(&self) -> Result<()> {
179        if self.schema != 1 {
180            return Err("unsupported configuration schema".into());
181        }
182        version(&self.rustup_version)?;
183        if self.max_download_bytes == 0 || self.max_download_bytes > 100 * 1024 * 1024 * 1024 {
184            return Err("download budget must be 1 byte to 100 GiB".into());
185        }
186        if self.selections.is_empty() || self.selections.len() > 32 {
187            return Err("select 1 to 32 named selections".into());
188        }
189        let mut names = BTreeSet::new();
190        for s in &self.selections {
191            s.validate()?;
192            if !names.insert(&s.name) {
193                return Err("selection names must be unique".into());
194            }
195        }
196        Ok(())
197    }
198    pub fn get(&self, name: &str) -> Result<&Selection> {
199        self.selections
200            .iter()
201            .find(|s| s.name == name)
202            .ok_or_else(|| format!("unknown selection: {name}").into())
203    }
204}
205fn read_file(path: &Path, limit: u64) -> Result<Vec<u8>> {
206    if !path.is_file() {
207        return Err("local input must be a regular file".into());
208    }
209    let mut data = Vec::new();
210    File::open(path)?.take(limit + 1).read_to_end(&mut data)?;
211    if data.len() as u64 > limit {
212        return Err("local file exceeds budget".into());
213    }
214    Ok(data)
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(deny_unknown_fields)]
219pub struct Artifact {
220    pub path: String,
221    pub sha256: String,
222    pub package: String,
223    pub target: String,
224    pub kind: String,
225}
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227#[serde(deny_unknown_fields)]
228pub struct Plan {
229    pub schema: u32,
230    pub planner_revision: u32,
231    pub rustup_version: String,
232    pub max_download_bytes: u64,
233    pub selection: Selection,
234    pub resolved_release: String,
235    pub date: String,
236    pub pinned_toolchain: String,
237    pub metadata_source: String,
238    pub manifest_sha256: String,
239    pub checksum_sha256: String,
240    pub signature_sha256: String,
241    pub bootstrap_checksums: BTreeMap<String, String>,
242    pub artifacts: Vec<Artifact>,
243}
244pub struct ResolvedPlan {
245    pub plan: Plan,
246    metadata: Metadata,
247}
248struct Metadata {
249    manifest: Vec<u8>,
250    checksum: Vec<u8>,
251    signature: Vec<u8>,
252    bootstraps: BTreeMap<String, Vec<u8>>,
253}
254fn parsed(raw: &[u8], spec: &Toolchain) -> Result<Manifest> {
255    let m: Manifest = toml::from_str(std::str::from_utf8(raw)?)?;
256    if m.manifest_version != "2" || !valid_date(&m.date) {
257        return Err("unsupported manifest schema or date".into());
258    }
259    if spec.date.as_ref().is_some_and(|d| d != &m.date) {
260        return Err("manifest date differs from requested date".into());
261    }
262    let release = m
263        .pkg
264        .get("rust")
265        .ok_or("missing rust metapackage")?
266        .version
267        .split_whitespace()
268        .next()
269        .ok_or("missing Rust version")?;
270    match spec.channel.as_str() {
271        "stable" => version(release)?,
272        "nightly" => version(
273            release
274                .strip_suffix("-nightly")
275                .ok_or("nightly endpoint returned a different channel")?,
276        )?,
277        "beta" => {
278            let (number, suffix) = release
279                .split_once("-beta")
280                .ok_or("beta endpoint returned a different channel")?;
281            version(number)?;
282            if !suffix.is_empty()
283                && (!suffix.starts_with('.')
284                    || suffix.len() < 2
285                    || !suffix[1..].bytes().all(|b| b.is_ascii_digit()))
286            {
287                return Err("invalid beta release identity".into());
288            }
289        }
290        _ => {
291            if release != spec.channel {
292                return Err("manifest does not match requested Rust version".into());
293            }
294        }
295    }
296    Ok(m)
297}
298fn canonical(m: &Manifest, name: &str) -> Result<String> {
299    let mut name = name.to_string();
300    let mut seen = BTreeSet::new();
301    while let Some(rename) = m.renames.get(&name) {
302        if !seen.insert(name.clone()) || seen.len() > 8 {
303            return Err("cyclic or excessive component aliases".into());
304        }
305        name = rename.to.clone();
306    }
307    atom(&name)?;
308    Ok(name)
309}
310fn add_artifact(map: &mut BTreeMap<String, Artifact>, a: Artifact) -> Result<()> {
311    safe_path(&a.path)?;
312    valid_hash(&a.sha256)?;
313    if let Some(old) = map.get(&a.path) {
314        if old.sha256 != a.sha256 {
315            return Err("conflicting artifact identities".into());
316        }
317    } else {
318        map.insert(a.path.clone(), a);
319    }
320    if map.len() > MAX_FILES {
321        return Err("plan exceeds artifact budget".into());
322    }
323    Ok(())
324}
325fn build_plan(selection: &Selection, rustup: &str, budget: u64, meta: &Metadata) -> Result<Plan> {
326    selection.validate()?;
327    version(rustup)?;
328    let spec = Toolchain::parse(&selection.toolchain)?;
329    let m = parsed(&meta.manifest, &spec)?;
330    if sha256(&meta.manifest) != checksum(&meta.checksum)? {
331        return Err("manifest checksum mismatch".into());
332    }
333    let mut artifacts = BTreeMap::new();
334    let root = m.pkg.get("rust").ok_or("missing rust package")?;
335    let profile = m
336        .profiles
337        .get(&selection.profile)
338        .ok_or("requested profile absent in this release")?;
339    for host in &selection.hosts {
340        let packages = root
341            .target
342            .get(host)
343            .filter(|t| t.available)
344            .ok_or_else(|| format!("host unavailable for {}: {host}", selection.toolchain))?;
345        let mut wanted = BTreeSet::new();
346        for input in profile.iter().chain(&selection.components) {
347            let package = canonical(&m, input)?;
348            let component = packages
349                .components
350                .iter()
351                .chain(&packages.extensions)
352                .find(|c| c.pkg == package && (c.target == *host || c.target == "*"));
353            if let Some(c) = component {
354                wanted.insert((c.pkg.clone(), c.target.clone()));
355            } else if selection.components.contains(input) {
356                return Err(
357                    format!("requested component {input} is not provided for {host}").into(),
358                );
359            }
360        }
361        for required in ["rustc", "cargo", "rust-std"] {
362            if !wanted.iter().any(|(p, _)| p == required) {
363                return Err(format!("profile lacks required {required} for {host}").into());
364            }
365        }
366        for target in &selection.targets {
367            let component = packages
368                .components
369                .iter()
370                .chain(&packages.extensions)
371                .find(|c| c.pkg == "rust-std" && c.target == *target)
372                .ok_or_else(|| format!("target {target} not advertised for host {host}"))?;
373            wanted.insert((component.pkg.clone(), component.target.clone()));
374        }
375        for (package, target) in wanted {
376            let p = m.pkg.get(&package).ok_or("dangling component package")?;
377            let t = p
378                .target
379                .get(&target)
380                .or_else(|| p.target.get("*"))
381                .filter(|t| t.available)
382                .ok_or_else(|| {
383                    format!(
384                        "component {package} for {target} unavailable in {}",
385                        selection.toolchain
386                    )
387                })?;
388            let variants = [
389                (&t.zst_url, &t.zst_hash),
390                (&t.xz_url, &t.xz_hash),
391                (&t.url, &t.hash),
392            ];
393            let mut found = false;
394            for (url, hash) in variants {
395                let (url, hash) = match (url, hash) {
396                    (Some(url), Some(hash)) => (url, hash),
397                    (None, None) => continue,
398                    _ => return Err("archive URL/checksum pair is incomplete".into()),
399                };
400                found = true;
401                let path = url.strip_prefix(ORIGIN).ok_or("foreign artifact origin")?;
402                safe_path(path)?;
403                if !path.starts_with(&format!("dist/{}/", m.date)) {
404                    return Err("artifact outside manifest release directory".into());
405                }
406                valid_hash(hash)?;
407                add_artifact(
408                    &mut artifacts,
409                    Artifact {
410                        path: path.into(),
411                        sha256: hash.to_ascii_lowercase(),
412                        package: package.clone(),
413                        target: target.clone(),
414                        kind: "component".into(),
415                    },
416                )?;
417            }
418            if !found {
419                return Err("available component has no downloadable archives".into());
420            }
421        }
422        let sha = meta
423            .bootstraps
424            .get(host)
425            .ok_or("missing bootstrap checksum metadata")?;
426        let path = bootstrap_path(rustup, host);
427        add_artifact(
428            &mut artifacts,
429            Artifact {
430                path,
431                sha256: checksum(sha)?,
432                package: "rustup-init".into(),
433                target: host.clone(),
434                kind: "bootstrap".into(),
435            },
436        )?;
437    }
438    Ok(Plan {
439        schema: 1,
440        planner_revision: 1,
441        rustup_version: rustup.into(),
442        max_download_bytes: budget,
443        selection: selection.clone(),
444        resolved_release: root
445            .version
446            .split_whitespace()
447            .next()
448            .ok_or("missing version")?
449            .into(),
450        date: m.date.clone(),
451        pinned_toolchain: spec.pinned(&m.date),
452        metadata_source: spec.manifest_path(),
453        manifest_sha256: sha256(&meta.manifest),
454        checksum_sha256: sha256(&meta.checksum),
455        signature_sha256: sha256(&meta.signature),
456        bootstrap_checksums: meta
457            .bootstraps
458            .iter()
459            .map(|(h, b)| (h.clone(), sha256(b)))
460            .collect(),
461        artifacts: artifacts.into_values().collect(),
462    })
463}
464fn bootstrap_path(version: &str, host: &str) -> String {
465    format!(
466        "rustup/archive/{version}/{host}/rustup-init{}",
467        if host.contains("-windows-") {
468            ".exe"
469        } else {
470            ""
471        }
472    )
473}
474fn metadata(toolchain: &str, source: &impl Source) -> Result<Metadata> {
475    let spec = Toolchain::parse(toolchain)?;
476    let path = spec.manifest_path();
477    let hash = bytes(source, &format!("{path}.sha256"), 4096)?;
478    let raw = bytes(source, &path, METADATA_LIMIT)?;
479    if sha256(&raw) != checksum(&hash)? {
480        return Err("upstream manifest checksum mismatch; retry resolution".into());
481    }
482    parsed(&raw, &spec)?;
483    let sig = bytes(source, &format!("{path}.asc"), 64 * 1024)?;
484    Ok(Metadata {
485        manifest: raw,
486        checksum: hash,
487        signature: sig,
488        bootstraps: BTreeMap::new(),
489    })
490}
491pub fn resolve(config: &Config, name: &str, source: &impl Source) -> Result<ResolvedPlan> {
492    config.validate()?;
493    let selected = config.get(name)?;
494    let mut meta = metadata(&selected.toolchain, source)?;
495    // Validate the component/host closure before asking for bootstrap metadata.
496    for host in &selected.hosts {
497        meta.bootstraps
498            .insert(host.clone(), format!("{}\n", "0".repeat(64)).into_bytes());
499    }
500    build_plan(
501        selected,
502        &config.rustup_version,
503        config.max_download_bytes,
504        &meta,
505    )?;
506    for host in &selected.hosts {
507        meta.bootstraps.insert(
508            host.clone(),
509            bytes(
510                source,
511                &format!("{}.sha256", bootstrap_path(&config.rustup_version, host)),
512                4096,
513            )?,
514        );
515    }
516    let plan = build_plan(
517        selected,
518        &config.rustup_version,
519        config.max_download_bytes,
520        &meta,
521    )?;
522    Ok(ResolvedPlan {
523        plan,
524        metadata: meta,
525    })
526}
527fn write_new(path: &Path, data: &[u8]) -> Result<()> {
528    let mut f = File::options().write(true).create_new(true).open(path)?;
529    f.write_all(data)?;
530    f.sync_all()?;
531    Ok(())
532}
533impl ResolvedPlan {
534    /// Persist only metadata and a preview/lock; no component archive is downloaded.
535    pub fn save(&self, output: &Path) -> Result<()> {
536        fs::create_dir(output)?;
537        write_new(&output.join("manifest.toml"), &self.metadata.manifest)?;
538        write_new(&output.join("manifest.sha256"), &self.metadata.checksum)?;
539        write_new(&output.join("manifest.asc"), &self.metadata.signature)?;
540        for (i, host) in self.plan.selection.hosts.iter().enumerate() {
541            write_new(
542                &output.join(format!("bootstrap-{i}.sha256")),
543                &self.metadata.bootstraps[host],
544            )?;
545        }
546        write_new(
547            &output.join("plan.toml"),
548            toml::to_string_pretty(&self.plan)?.as_bytes(),
549        )?;
550        Ok(())
551    }
552}
553fn cache_read(root: &Path, name: &str, limit: u64) -> Result<Vec<u8>> {
554    let p = root.join(name);
555    if fs::symlink_metadata(&p)?.file_type().is_symlink() || !p.is_file() {
556        return Err("plan metadata must be regular files".into());
557    }
558    read_file(&p, limit)
559}
560pub fn load_plan(root: &Path) -> Result<ResolvedPlan> {
561    if fs::symlink_metadata(root)?.file_type().is_symlink() || !root.is_dir() {
562        return Err("plan must be a real directory".into());
563    }
564    let plan: Plan = toml::from_str(std::str::from_utf8(&cache_read(
565        root,
566        "plan.toml",
567        SMALL_LIMIT,
568    )?)?)?;
569    if plan.schema != 1 || plan.planner_revision != 1 {
570        return Err("unsupported frozen plan schema/revision".into());
571    }
572    Config {
573        schema: 1,
574        rustup_version: plan.rustup_version.clone(),
575        max_download_bytes: plan.max_download_bytes,
576        selections: vec![plan.selection.clone()],
577    }
578    .validate()?;
579    let mut meta = Metadata {
580        manifest: cache_read(root, "manifest.toml", METADATA_LIMIT)?,
581        checksum: cache_read(root, "manifest.sha256", 4096)?,
582        signature: cache_read(root, "manifest.asc", 64 * 1024)?,
583        bootstraps: BTreeMap::new(),
584    };
585    let mut expected: BTreeSet<String> = [
586        "plan.toml",
587        "manifest.toml",
588        "manifest.sha256",
589        "manifest.asc",
590    ]
591    .iter()
592    .map(|s| s.to_string())
593    .collect();
594    for (i, host) in plan.selection.hosts.iter().enumerate() {
595        let name = format!("bootstrap-{i}.sha256");
596        meta.bootstraps
597            .insert(host.clone(), cache_read(root, &name, 4096)?);
598        expected.insert(name);
599    }
600    for item in fs::read_dir(root)? {
601        let item = item?;
602        let name = item
603            .file_name()
604            .into_string()
605            .map_err(|_| "invalid plan file name")?;
606        if !expected.remove(&name) {
607            return Err("unexpected plan files".into());
608        }
609    }
610    if !expected.is_empty() {
611        return Err("incomplete plan directory".into());
612    }
613    if build_plan(
614        &plan.selection,
615        &plan.rustup_version,
616        plan.max_download_bytes,
617        &meta,
618    )? != plan
619    {
620        return Err("frozen plan differs from cached metadata; resolve a new plan".into());
621    }
622    Ok(ResolvedPlan {
623        plan,
624        metadata: meta,
625    })
626}
627
628#[derive(Serialize, Deserialize, PartialEq, Eq)]
629#[serde(deny_unknown_fields)]
630pub struct SelectionSnapshot {
631    pub schema: u32,
632    pub release: String,
633    pub date: String,
634    pub pinned_toolchain: String,
635    pub rustup_version: String,
636    pub selection: Selection,
637    pub files: Vec<SnapshotFile>,
638}
639fn snapshot_metadata(resolved: &ResolvedPlan) -> Result<BTreeMap<String, &Vec<u8>>> {
640    let plan = &resolved.plan;
641    let meta = &resolved.metadata;
642    let spec = Toolchain::parse(&plan.selection.toolchain)?;
643    let mut files = BTreeMap::new();
644    for path in [
645        plan.metadata_source.clone(),
646        format!("dist/{}/channel-rust-{}.toml", plan.date, spec.channel),
647    ] {
648        files.insert(path.clone(), &meta.manifest);
649        files.insert(format!("{path}.sha256"), &meta.checksum);
650        files.insert(format!("{path}.asc"), &meta.signature);
651    }
652    for host in &plan.selection.hosts {
653        files.insert(
654            format!("{}.sha256", bootstrap_path(&plan.rustup_version, host)),
655            &meta.bootstraps[host],
656        );
657    }
658    Ok(files)
659}
660fn snapshot(plan: &Plan, files: Vec<SnapshotFile>) -> SelectionSnapshot {
661    SelectionSnapshot {
662        schema: 2,
663        release: plan.resolved_release.clone(),
664        date: plan.date.clone(),
665        pinned_toolchain: plan.pinned_toolchain.clone(),
666        rustup_version: plan.rustup_version.clone(),
667        selection: plan.selection.clone(),
668        files,
669    }
670}
671/// Fetch a frozen plan into a new output; retain private progress if interrupted.
672pub fn fetch(plan_dir: &Path, output: &Path, source: &impl Source) -> Result<PathBuf> {
673    fetch_with_resume(plan_dir, output, source, false, None)
674}
675/// Explicitly reopen an existing output for this exact frozen plan. Reuse requires
676/// whole-file verification; a valid completed snapshot is checked, never overwritten.
677pub fn fetch_resuming(plan_dir: &Path, output: &Path, source: &impl Source) -> Result<PathBuf> {
678    fetch_with_resume(plan_dir, output, source, true, None)
679}
680fn fetch_limited(
681    plan: &Path,
682    output: &Path,
683    source: &impl Source,
684    resume: bool,
685    limit: u64,
686) -> Result<PathBuf> {
687    fetch_with_resume(plan, output, source, resume, Some(limit))
688}
689fn fetch_with_resume(
690    plan_dir: &Path,
691    output: &Path,
692    source: &impl Source,
693    resume: bool,
694    limit: Option<u64>,
695) -> Result<PathBuf> {
696    let resolved = load_plan(plan_dir)?;
697    let plan = &resolved.plan;
698    let metadata = snapshot_metadata(&resolved)?;
699    let expected: BTreeSet<PathBuf> = plan
700        .artifacts
701        .iter()
702        .map(|a| PathBuf::from(&a.path))
703        .chain(metadata.keys().map(PathBuf::from))
704        .chain([PathBuf::from("snapshot.toml")])
705        .collect();
706    let session = crate::resume::Session::open(output, plan, resume)?;
707    let completed = output.join("mirror");
708    if fs::symlink_metadata(&completed).is_ok() {
709        crate::resume::validate_tree(&completed, &expected)?;
710        let actual: SelectionSnapshot = toml::from_str(std::str::from_utf8(&read_file(
711            &completed.join("snapshot.toml"),
712            SMALL_LIMIT,
713        )?)?)?;
714        let mut files = Vec::new();
715        let mut remaining = plan.max_download_bytes.min(limit.unwrap_or(u64::MAX));
716        for a in &plan.artifacts {
717            let (n, h) =
718                crate::resume::hash_file(&completed.join(&a.path), remaining.min(ARTIFACT_LIMIT))?;
719            if h != a.sha256 {
720                return Err("completed snapshot changed; refusing overwrite".into());
721            }
722            remaining -= n;
723            files.push(SnapshotFile {
724                path: a.path.clone(),
725                bytes: n,
726                sha256: h,
727            });
728        }
729        for (path, data) in &metadata {
730            let (n, h) =
731                crate::resume::hash_file(&completed.join(path), remaining.min(ARTIFACT_LIMIT))?;
732            if n != data.len() as u64 || h != sha256(data) {
733                return Err("completed metadata changed; refusing overwrite".into());
734            }
735            remaining -= n;
736            files.push(SnapshotFile {
737                path: path.clone(),
738                bytes: n,
739                sha256: h,
740            });
741        }
742        files.sort_by(|a, b| a.path.cmp(&b.path));
743        if actual != snapshot(plan, files) {
744            return Err("completed inventory differs from frozen plan".into());
745        }
746        session.cleanup()?;
747        return Ok(completed);
748    }
749    let staging = session.stage(&expected)?;
750    let mut files = Vec::new();
751    let mut remaining = plan.max_download_bytes.min(limit.unwrap_or(u64::MAX));
752    for a in &plan.artifacts {
753        let limit = remaining.min(ARTIFACT_LIMIT);
754        if limit == 0 {
755            return Err("snapshot download budget exhausted".into());
756        }
757        let (path, n) = session.retrieve(a, source, limit)?;
758        let destination = staging.join(&a.path);
759        fs::create_dir_all(destination.parent().ok_or("missing parent")?)?;
760        let mut file = File::create_new(destination)?;
761        let mut writer = crate::HashWriter {
762            file: &mut file,
763            hash: sha2::Sha256::default(),
764            bytes: 0,
765        };
766        crate::copy_bounded(File::open(path)?, &mut writer, limit)?;
767        let count = writer.bytes;
768        let h = hex::encode(sha2::Digest::finalize(writer.hash));
769        if count != n || h != a.sha256 {
770            return Err("cached artifact changed while assembling".into());
771        }
772        file.sync_all()?;
773        remaining -= count;
774        files.push(SnapshotFile {
775            path: a.path.clone(),
776            bytes: count,
777            sha256: h,
778        });
779    }
780    for (path, data) in metadata {
781        if data.len() as u64 > remaining {
782            return Err("snapshot budget exhausted by metadata".into());
783        }
784        remaining -= data.len() as u64;
785        crate::save(&staging, &path, data, &mut files)?;
786    }
787    files.sort_by(|a, b| a.path.cmp(&b.path));
788    write_new(
789        &staging.join("snapshot.toml"),
790        toml::to_string_pretty(&snapshot(plan, files))?.as_bytes(),
791    )?;
792    if fs::symlink_metadata(&completed).is_ok() {
793        return Err("completed tree unexpectedly exists".into());
794    }
795    fs::rename(&staging, &completed)?;
796    session.cleanup()?;
797    Ok(completed)
798}
799
800#[derive(Debug, Serialize)]
801pub struct Catalog {
802    pub toolchain: String,
803    pub release: String,
804    pub date: String,
805    pub hosts: Vec<Availability>,
806    pub components: Vec<ComponentAvailability>,
807    pub targets: Vec<Availability>,
808    pub profiles: Vec<String>,
809    pub supported_profiles: Vec<String>,
810}
811#[derive(Debug, Serialize)]
812pub struct Availability {
813    pub name: String,
814    pub available: bool,
815}
816#[derive(Debug, Serialize)]
817pub struct ComponentAvailability {
818    pub package: String,
819    pub aliases: Vec<String>,
820    pub target: String,
821    pub available: bool,
822    pub manifest_package_version: String,
823}
824/// Inspect upstream metadata. Availability is not native platform/runtime qualification.
825pub fn catalog(toolchain: &str, host: Option<&str>, source: &impl Source) -> Result<Catalog> {
826    let meta = metadata(toolchain, source)?;
827    let m = parsed(&meta.manifest, &Toolchain::parse(toolchain)?)?;
828    let root = m.pkg.get("rust").ok_or("missing rust metapackage")?;
829    let hosts = root
830        .target
831        .iter()
832        .filter(|(h, _)| h.as_str() != "*")
833        .map(|(h, t)| Availability {
834            name: h.clone(),
835            available: t.available,
836        })
837        .collect();
838    let mut components = BTreeMap::new();
839    let mut targets = BTreeMap::new();
840    if let Some(host) = host {
841        atom(host)?;
842        let t = root
843            .target
844            .get(host)
845            .filter(|t| t.available)
846            .ok_or("host unavailable in this manifest")?;
847        for c in t.components.iter().chain(&t.extensions) {
848            let p = m.pkg.get(&c.pkg).ok_or("dangling component package")?;
849            let target = p
850                .target
851                .get(&c.target)
852                .or_else(|| p.target.get("*"))
853                .ok_or("dangling target metadata")?;
854            if c.pkg == "rust-std" {
855                targets.insert(
856                    c.target.clone(),
857                    Availability {
858                        name: c.target.clone(),
859                        available: target.available,
860                    },
861                );
862            }
863            if c.target == host || c.target == "*" {
864                components.insert(
865                    (c.pkg.clone(), c.target.clone()),
866                    ComponentAvailability {
867                        package: c.pkg.clone(),
868                        aliases: m
869                            .renames
870                            .iter()
871                            .filter(|(_, r)| r.to == c.pkg)
872                            .map(|(a, _)| a.clone())
873                            .collect(),
874                        target: c.target.clone(),
875                        available: target.available,
876                        manifest_package_version: p.version.clone(),
877                    },
878                );
879            }
880        }
881    }
882    Ok(Catalog {
883        toolchain: toolchain.into(),
884        release: root.version.clone(),
885        date: m.date,
886        hosts,
887        components: components.into_values().collect(),
888        targets: targets.into_values().collect(),
889        supported_profiles: m
890            .profiles
891            .keys()
892            .filter(|p| ["minimal", "default"].contains(&p.as_str()))
893            .cloned()
894            .collect(),
895        profiles: m.profiles.into_keys().collect(),
896    })
897}
898#[derive(Deserialize)]
899#[serde(deny_unknown_fields)]
900struct ToolchainFile {
901    toolchain: ImportedToolchain,
902}
903#[derive(Deserialize)]
904#[serde(deny_unknown_fields)]
905struct ImportedToolchain {
906    channel: Option<String>,
907    path: Option<String>,
908    profile: Option<String>,
909    #[serde(default)]
910    components: Vec<String>,
911    #[serde(default)]
912    targets: Vec<String>,
913}
914/// Import one project toolchain file. Hosts and bootstrap version remain explicit.
915pub fn import_toolchain(
916    path: &Path,
917    name: &str,
918    hosts: Vec<String>,
919    rustup: &str,
920) -> Result<Config> {
921    let raw = read_file(path, SMALL_LIMIT)?;
922    let text = std::str::from_utf8(&raw)?;
923    let imported = if path.file_name().is_some_and(|n| n == "rust-toolchain")
924        && !text.trim_start().starts_with('[')
925    {
926        let channel = text.trim();
927        if !channel.is_ascii() {
928            return Err("legacy toolchain file must be ASCII".into());
929        }
930        ImportedToolchain {
931            channel: Some(channel.into()),
932            path: None,
933            profile: None,
934            components: Vec::new(),
935            targets: Vec::new(),
936        }
937    } else {
938        toml::from_str::<ToolchainFile>(text)?.toolchain
939    };
940    if imported.path.is_some() {
941        return Err("local custom toolchain paths cannot be mirrored".into());
942    }
943    let selection = Selection {
944        name: name.into(),
945        toolchain: imported
946            .channel
947            .ok_or("toolchain file needs an explicit channel")?,
948        hosts,
949        profile: imported.profile.unwrap_or_else(default_profile),
950        components: imported.components,
951        targets: imported.targets,
952    };
953    let config = Config {
954        schema: 1,
955        rustup_version: rustup.into(),
956        max_download_bytes: default_budget(),
957        selections: vec![selection],
958    };
959    config.validate()?;
960    Ok(config)
961}
962pub fn write_config(config: &Config, path: &Path) -> Result<()> {
963    config.validate()?;
964    write_new(path, toml::to_string_pretty(config)?.as_bytes())
965}