Skip to main content

rust_mirror/selection/
rustup.rs

1//! Complete a verified generation with pinned Rustup distribution endpoints.
2use super::{
3    collection::{self, CollectionSnapshot},
4    read_file, Toolchain,
5};
6use crate::{bytes, checksum, sha256, Result, SnapshotFile, Source, ARTIFACT_LIMIT};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fs::{self, File},
10    path::{Path, PathBuf},
11};
12
13/// Copy a selected/collection snapshot into a fresh generation, add Rustup's
14/// pinned catalog/installers, and explicitly promote dated channel triplets.
15/// The catalog is generated mirror policy; Rust manifests and shell script bytes
16/// remain upstream originals. No input or remote server is modified.
17pub fn prepare(
18    snapshot: &Path,
19    rustup_version: &str,
20    channels: &[String],
21    output: &Path,
22    max_bytes: u64,
23    source: &impl Source,
24) -> Result<PathBuf> {
25    crate::version(rustup_version)?;
26    if max_bytes == 0 || max_bytes > 100 * 1024 * 1024 * 1024 || channels.len() > 3 {
27        return Err("invalid Rustup generation budget/channel count".into());
28    }
29    if output.symlink_metadata().is_ok() {
30        return Err("Rustup output already exists; select a new path".into());
31    }
32    let parent = output
33        .parent()
34        .filter(|p| !p.as_os_str().is_empty())
35        .unwrap_or(Path::new("."));
36    let target = parent
37        .canonicalize()?
38        .join(output.file_name().ok_or("output needs a directory name")?);
39    if target.starts_with(snapshot.canonicalize()?) {
40        return Err("Rustup output cannot be inside its input snapshot".into());
41    }
42    let input = collection::read_input(snapshot)?;
43    let initial_bytes = input.files.values().try_fold(0u64, |n, e| {
44        n.checked_add(e.bytes)
45            .ok_or("Rustup input byte count overflow")
46    })?;
47    if initial_bytes > max_bytes {
48        return Err("Rustup input already exceeds generation budget".into());
49    }
50    let hosts: BTreeSet<_> = input
51        .records
52        .iter()
53        .flat_map(|r| r.selection.hosts.iter().cloned())
54        .collect();
55    if hosts.is_empty() || hosts.len() > 64 {
56        return Err("Rustup host limit exceeded".into());
57    }
58    let mut metadata = BTreeMap::new();
59    let catalog = format!("schema-version = '1'\nversion = '{rustup_version}'\n").into_bytes();
60    metadata.insert("rustup/release-stable.toml".to_string(), catalog);
61    metadata.insert(
62        "rustup/rustup-init.sh".to_string(),
63        bytes(source, "rustup/rustup-init.sh", 256 * 1024)?,
64    );
65    let mut installers = Vec::new();
66    for host in hosts {
67        let archive = super::bootstrap_path(rustup_version, &host);
68        let sidecar = bytes(source, &format!("{archive}.sha256"), 4096)?;
69        let hash = checksum(&sidecar)?;
70        let current = format!(
71            "rustup/dist/{host}/rustup-init{}",
72            if host.contains("-windows-") {
73                ".exe"
74            } else {
75                ""
76            }
77        );
78        metadata.insert(format!("{archive}.sha256"), sidecar.clone());
79        metadata.insert(format!("{current}.sha256"), sidecar);
80        installers.push((archive, current, hash));
81    }
82    // Rustup clients accept both full and major.minor numeric selectors. Publish
83    // policy aliases using the highest selected stable patch, preserving every
84    // original signed manifest byte. Tool selection itself remains explicitly pinned.
85    let mut minor = BTreeMap::new();
86    for item in &input.records {
87        if crate::version(&item.release).is_err() {
88            continue;
89        }
90        let numbers = item
91            .release
92            .split('.')
93            .map(str::parse::<u64>)
94            .collect::<std::result::Result<Vec<_>, _>>()?;
95        let key = format!("{}.{}", numbers[0], numbers[1]);
96        let spec = Toolchain::parse(&item.selection.toolchain)?;
97        let canonical = format!("dist/channel-rust-{}.toml", item.release);
98        let dated = format!("dist/{}/channel-rust-{}.toml", item.date, spec.channel);
99        if let Some(existing) = input.files.get(&canonical) {
100            if input
101                .files
102                .get(&dated)
103                .is_none_or(|e| e.sha256 != existing.sha256)
104            {
105                return Err("ambiguous canonical manifest for a numeric alias".into());
106            }
107        }
108        let stem = if input.files.contains_key(&canonical) {
109            canonical.clone()
110        } else {
111            format!("dist/{}/channel-rust-{}.toml", item.date, spec.channel)
112        };
113        if !input.files.contains_key(&canonical) {
114            for suffix in ["", ".sha256", ".asc"] {
115                let name = format!("{stem}{suffix}");
116                let entry = input
117                    .files
118                    .get(&name)
119                    .ok_or("numeric alias lacks original metadata")?;
120                metadata.insert(
121                    format!("{canonical}{suffix}"),
122                    read_file(&entry.source, crate::METADATA_LIMIT)?,
123                );
124            }
125        }
126        if minor
127            .get(&key)
128            .is_none_or(|(patch, _, _): &(u64, String, String)| numbers[2] > *patch)
129        {
130            minor.insert(key, (numbers[2], item.release.clone(), stem));
131        }
132    }
133    let mut alias_catalog = BTreeMap::new();
134    for (alias, (_, release, stem)) in minor {
135        for suffix in ["", ".sha256", ".asc"] {
136            let entry = input
137                .files
138                .get(&format!("{stem}{suffix}"))
139                .ok_or("minor alias lacks original metadata")?;
140            metadata.insert(
141                format!("dist/channel-rust-{alias}.toml{suffix}"),
142                read_file(&entry.source, crate::METADATA_LIMIT)?,
143            );
144        }
145        alias_catalog.insert(alias, release);
146    }
147    metadata.insert(
148        "rustup/toolchain-aliases.toml".into(),
149        format!(
150            "schema = 1\n[aliases]\n{}",
151            toml::to_string(&alias_catalog)?
152        )
153        .into_bytes(),
154    );
155    let mut seen = BTreeSet::new();
156    for requested in channels {
157        let spec = Toolchain::parse(requested)?;
158        if !["stable", "beta", "nightly"].contains(&spec.channel.as_str())
159            || spec.date.is_none()
160            || !seen.insert(spec.channel.clone())
161        {
162            return Err(
163                "promote one explicit dated selector per channel, e.g. stable-2025-09-18".into(),
164            );
165        }
166        let date = spec.date.as_deref().unwrap();
167        if !input.records.iter().any(|r| {
168            r.date == date
169                && Toolchain::parse(&r.selection.toolchain).is_ok_and(|s| s.channel == spec.channel)
170        }) {
171            return Err("promotion requires a matching verified dated channel selection".into());
172        }
173        for suffix in ["", ".sha256", ".asc"] {
174            let from = format!("dist/{date}/channel-rust-{}.toml{suffix}", spec.channel);
175            let to = format!("dist/channel-rust-{}.toml{suffix}", spec.channel);
176            let item = input
177                .files
178                .get(&from)
179                .ok_or("promotion lacks original metadata triplet")?;
180            metadata.insert(to, read_file(&item.source, crate::METADATA_LIMIT)?);
181        }
182    }
183    // Every metadata decision is frozen before installer payload transfers.
184    fs::create_dir(output)?;
185    let stage = tempfile::Builder::new()
186        .prefix(".rustup-")
187        .tempdir_in(output)?;
188    let root = stage.path();
189    let mut entries = BTreeMap::new();
190    let mut total = 0u64;
191    for (name, entry) in &input.files {
192        let dest = root.join(name);
193        fs::create_dir_all(dest.parent().unwrap())?;
194        let mut out = File::create(&dest)?;
195        crate::copy_bounded(File::open(&entry.source)?, &mut out, entry.bytes)?;
196        out.sync_all()?;
197        if crate::resume::hash_file(&dest, ARTIFACT_LIMIT)? != (entry.bytes, entry.sha256.clone()) {
198            return Err("input changed during Rustup generation copy".into());
199        }
200        record(
201            &mut entries,
202            name,
203            entry.bytes,
204            entry.sha256.clone(),
205            &mut total,
206            max_bytes,
207        )?;
208    }
209    for (archive, current, expected) in installers {
210        if entries
211            .get(&archive)
212            .is_none_or(|f: &SnapshotFile| f.sha256 != expected)
213        {
214            let path = root.join(&archive);
215            if entries.contains_key(&archive) {
216                return Err("immutable Rustup archive checksum conflict".into());
217            }
218            fs::create_dir_all(path.parent().unwrap())?;
219            let mut out = File::create(&path)?;
220            source.fetch(
221                &format!("{}{archive}", crate::ORIGIN),
222                &mut out,
223                (max_bytes - total).min(ARTIFACT_LIMIT),
224            )?;
225            out.sync_all()?;
226            let (n, actual) = crate::resume::hash_file(&path, ARTIFACT_LIMIT)?;
227            if actual != expected {
228                return Err("Rustup installer checksum mismatch".into());
229            }
230            record(&mut entries, &archive, n, actual, &mut total, max_bytes)?;
231        }
232        let entry = &entries[&archive];
233        let (n, hash) = (entry.bytes, entry.sha256.clone());
234        record(&mut entries, &current, n, hash, &mut total, max_bytes)?;
235        let path = root.join(&current);
236        fs::create_dir_all(path.parent().unwrap())?;
237        let mut out = File::create(&path)?;
238        crate::copy_bounded(File::open(root.join(&archive))?, &mut out, n)?;
239        out.sync_all()?;
240    }
241    for (name, data) in metadata {
242        let dest = root.join(&name);
243        fs::create_dir_all(dest.parent().unwrap())?;
244        record(
245            &mut entries,
246            &name,
247            data.len() as u64,
248            sha256(&data),
249            &mut total,
250            max_bytes,
251        )?;
252        {
253            use std::io::Write;
254            let mut f = File::create(&dest)?;
255            f.write_all(&data)?;
256            f.sync_all()?;
257        }
258    }
259    let inventory = CollectionSnapshot {
260        schema: 3,
261        rustup_version: rustup_version.into(),
262        toolchains: input.records,
263        inputs: vec![input.identity],
264        files: entries.into_values().collect(),
265    };
266    let raw = toml::to_string_pretty(&inventory)?;
267    if raw.len() > 1024 * 1024 {
268        return Err("Rustup inventory exceeds byte limit".into());
269    }
270    super::write_new(&root.join("snapshot.toml"), raw.as_bytes())?;
271    collection::read_input(root)?;
272    let completed = output.join("mirror");
273    fs::rename(root, &completed)?;
274    Ok(completed)
275}
276fn record(
277    files: &mut BTreeMap<String, SnapshotFile>,
278    name: &str,
279    bytes: u64,
280    hash: String,
281    total: &mut u64,
282    budget: u64,
283) -> Result<()> {
284    let prior = files.get(name).map(|f| f.bytes).unwrap_or(0);
285    *total = total
286        .checked_sub(prior)
287        .and_then(|v| v.checked_add(bytes))
288        .ok_or("Rustup byte count overflow")?;
289    if *total > budget || (!files.contains_key(name) && files.len() >= 4096) {
290        return Err("Rustup generation exceeds byte/file budget".into());
291    }
292    files.insert(
293        name.into(),
294        SnapshotFile {
295            path: name.into(),
296            bytes,
297            sha256: hash,
298        },
299    );
300    Ok(())
301}