Skip to main content

rust_mirror/selection/
collection.rs

1//! Immutable multi-version generations assembled from verified local snapshots.
2use super::{parsed, read_file, Selection, SelectionSnapshot, Toolchain};
3use crate::{
4    checksum, safe_path, sha256, valid_hash, Result, SnapshotFile, ARTIFACT_LIMIT, METADATA_LIMIT,
5};
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fs::{self, File},
10    path::{Path, PathBuf},
11};
12const INVENTORY_LIMIT: u64 = 1024 * 1024;
13const FILE_LIMIT: usize = 4096;
14#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
15#[serde(deny_unknown_fields)]
16pub struct ToolchainRecord {
17    pub selection: Selection,
18    pub release: String,
19    pub date: String,
20    pub pinned_toolchain: String,
21}
22#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields)]
24pub struct CollectionSnapshot {
25    pub schema: u32,
26    pub rustup_version: String,
27    pub toolchains: Vec<ToolchainRecord>,
28    /// SHA-256 identities of directly supplied inventories, including the previous generation.
29    pub inputs: Vec<String>,
30    pub files: Vec<SnapshotFile>,
31}
32#[derive(Clone)]
33pub(super) struct Entry {
34    pub(super) source: PathBuf,
35    pub(super) sha256: String,
36    pub(super) bytes: u64,
37}
38pub(super) struct Input {
39    pub(super) rustup: String,
40    pub(super) records: Vec<ToolchainRecord>,
41    pub(super) files: BTreeMap<String, Entry>,
42    pub(super) identity: String,
43}
44pub(super) fn rolling(path: &str) -> bool {
45    ["stable", "beta", "nightly"].iter().any(|c| {
46        [
47            format!("dist/channel-rust-{c}.toml"),
48            format!("dist/channel-rust-{c}.toml.sha256"),
49            format!("dist/channel-rust-{c}.toml.asc"),
50        ]
51        .iter()
52        .any(|p| p == path)
53    })
54}
55pub(super) fn read_input(root: &Path) -> Result<Input> {
56    let raw = read_file(&root.join("snapshot.toml"), INVENTORY_LIMIT)?;
57    let value: toml::Value = toml::from_str(std::str::from_utf8(&raw)?)?;
58    let (rustup, records, files) =
59        match value.get("schema").and_then(toml::Value::as_integer) {
60            Some(2) => {
61                let s: SelectionSnapshot = toml::from_str(std::str::from_utf8(&raw)?)?;
62                (
63                    s.rustup_version,
64                    vec![ToolchainRecord {
65                        selection: s.selection,
66                        release: s.release,
67                        date: s.date,
68                        pinned_toolchain: s.pinned_toolchain,
69                    }],
70                    s.files,
71                )
72            }
73            Some(3) => {
74                let s: CollectionSnapshot = toml::from_str(std::str::from_utf8(&raw)?)?;
75                if s.inputs.len() > 33 || s.inputs.is_empty() {
76                    return Err("invalid collection input identities".into());
77                }
78                for identity in &s.inputs {
79                    valid_hash(identity)?;
80                }
81                (s.rustup_version, s.toolchains, s.files)
82            }
83            _ => return Err(
84                "assemble accepts selected schema 2 or collection schema 3, not legacy snapshots"
85                    .into(),
86            ),
87        };
88    crate::version(&rustup)?;
89    if records.is_empty() || records.len() > 256 || files.is_empty() || files.len() > FILE_LIMIT {
90        return Err("collection record/file limit exceeded".into());
91    }
92    let mut entries = BTreeMap::new();
93    let mut expected = BTreeSet::from([PathBuf::from("snapshot.toml")]);
94    for item in files {
95        safe_path(&item.path)?;
96        valid_hash(&item.sha256)?;
97        if item.path == "snapshot.toml"
98            || item.bytes > ARTIFACT_LIMIT
99            || entries.contains_key(&item.path)
100        {
101            return Err("invalid duplicate/reserved source inventory entry".into());
102        }
103        expected.insert(PathBuf::from(&item.path));
104        entries.insert(
105            item.path.clone(),
106            Entry {
107                source: root.join(item.path),
108                sha256: item.sha256,
109                bytes: item.bytes,
110            },
111        );
112    }
113    crate::resume::validate_tree(root, &expected)?;
114    for entry in entries.values() {
115        if crate::resume::hash_file(&entry.source, entry.bytes)?
116            != (entry.bytes, entry.sha256.clone())
117        {
118            return Err("input snapshot bytes differ from its inventory".into());
119        }
120    }
121    for record in &records {
122        record.selection.validate()?;
123        let spec = Toolchain::parse(&record.selection.toolchain)?;
124        // Historical moving-channel records bind to dated metadata, never today's root alias.
125        let path = format!("dist/{}/channel-rust-{}.toml", record.date, spec.channel);
126        safe_path(&path)?;
127        let manifest = entries
128            .get(&path)
129            .ok_or("missing dated toolchain manifest")?;
130        let hash = entries
131            .get(&format!("{path}.sha256"))
132            .ok_or("missing dated manifest checksum")?;
133        if !entries.contains_key(&format!("{path}.asc")) {
134            return Err("missing original signature file".into());
135        }
136        let bytes = read_file(&manifest.source, METADATA_LIMIT)?;
137        if checksum(&read_file(&hash.source, 4096)?)? != sha256(&bytes) {
138            return Err("source manifest checksum mismatch".into());
139        }
140        let metadata = super::Metadata {
141            manifest: bytes.clone(),
142            checksum: read_file(&hash.source, 4096)?,
143            signature: read_file(&entries[&format!("{path}.asc")].source, 64 * 1024)?,
144            bootstraps: record
145                .selection
146                .hosts
147                .iter()
148                .map(|host| {
149                    let file = format!("{}.sha256", super::bootstrap_path(&rustup, host));
150                    Ok((
151                        host.clone(),
152                        read_file(
153                            &entries
154                                .get(&file)
155                                .ok_or("missing bootstrap sidecar")?
156                                .source,
157                            4096,
158                        )?,
159                    ))
160                })
161                .collect::<Result<_>>()?,
162        };
163        let expected = super::build_plan(
164            &record.selection,
165            &rustup,
166            100 * 1024 * 1024 * 1024,
167            &metadata,
168        )?;
169        for a in &expected.artifacts {
170            if entries.get(&a.path).is_none_or(|e| e.sha256 != a.sha256) {
171                return Err("source omits a selected artifact or has a different identity".into());
172            }
173        }
174        let m = parsed(&bytes, &spec)?;
175        let release = m
176            .pkg
177            .get("rust")
178            .ok_or("missing rust package")?
179            .version
180            .split_whitespace()
181            .next()
182            .ok_or("missing Rust version")?;
183        if m.date != record.date
184            || release != record.release
185            || record.pinned_toolchain != spec.pinned(&m.date)
186        {
187            return Err("toolchain record differs from preserved manifest".into());
188        }
189    }
190    // A replacement root channel is a complete original triplet, never mixed metadata.
191    for channel in ["stable", "beta", "nightly"] {
192        let path = format!("dist/channel-rust-{channel}.toml");
193        let names = [
194            path.clone(),
195            format!("{path}.sha256"),
196            format!("{path}.asc"),
197        ];
198        let present = names.iter().filter(|p| entries.contains_key(*p)).count();
199        if present != 0 {
200            if present != 3 {
201                return Err("incomplete moving-channel metadata triplet".into());
202            }
203            if checksum(&read_file(&entries[&names[1]].source, 4096)?)? != entries[&path].sha256 {
204                return Err("moving-channel checksum mismatch".into());
205            }
206            let m = parsed(
207                &read_file(&entries[&path].source, METADATA_LIMIT)?,
208                &Toolchain::parse(channel)?,
209            )?;
210            let dated = format!("dist/{}/channel-rust-{channel}.toml", m.date);
211            if entries
212                .get(&dated)
213                .is_none_or(|e| e.sha256 != entries[&path].sha256)
214                || !records.iter().any(|r| {
215                    r.date == m.date
216                        && Toolchain::parse(&r.selection.toolchain)
217                            .is_ok_and(|s| s.channel == channel)
218                })
219            {
220                return Err("moving alias lacks matching dated toolchain record".into());
221            }
222        }
223    }
224    if let Some(catalog) = entries.get("rustup/release-stable.toml") {
225        let doc: toml::Value =
226            toml::from_str(std::str::from_utf8(&read_file(&catalog.source, 4096)?)?)?;
227        if doc.get("schema-version").and_then(toml::Value::as_str) != Some("1")
228            || doc.get("version").and_then(toml::Value::as_str) != Some(rustup.as_str())
229        {
230            return Err("Rustup catalog differs from inventory version".into());
231        }
232        for host in records.iter().flat_map(|r| &r.selection.hosts) {
233            let archive = super::bootstrap_path(&rustup, host);
234            let current = format!(
235                "rustup/dist/{host}/rustup-init{}",
236                if host.contains("-windows-") {
237                    ".exe"
238                } else {
239                    ""
240                }
241            );
242            let installer = entries.get(&archive).ok_or("missing Rustup archive")?;
243            let alias = entries
244                .get(&current)
245                .ok_or("missing current Rustup installer")?;
246            let sidecar = entries
247                .get(&format!("{current}.sha256"))
248                .ok_or("missing current Rustup checksum")?;
249            if alias.sha256 != installer.sha256
250                || checksum(&read_file(&sidecar.source, 4096)?)? != alias.sha256
251            {
252                return Err("Rustup current installer differs from pinned archive".into());
253            }
254        }
255        if !entries.contains_key("rustup/rustup-init.sh") {
256            return Err("missing original Rustup shell installer".into());
257        }
258    }
259    if let Some(aliases) = entries.get("rustup/toolchain-aliases.toml") {
260        let doc: toml::Value = toml::from_str(std::str::from_utf8(&read_file(
261            &aliases.source,
262            INVENTORY_LIMIT,
263        )?)?)?;
264        if doc.get("schema").and_then(toml::Value::as_integer) != Some(1) {
265            return Err("invalid numeric alias schema".into());
266        }
267        let table = doc
268            .get("aliases")
269            .and_then(toml::Value::as_table)
270            .ok_or("missing numeric aliases")?;
271        if table.len() > 256 {
272            return Err("numeric alias limit exceeded".into());
273        }
274        for (alias, value) in table {
275            let full = value.as_str().ok_or("invalid numeric alias version")?;
276            crate::version(full)?;
277            if full.rsplit_once('.').map(|(prefix, _)| prefix) != Some(alias.as_str()) {
278                return Err("numeric alias/version mismatch".into());
279            }
280            let exact = format!("dist/channel-rust-{full}.toml");
281            let minor = format!("dist/channel-rust-{alias}.toml");
282            let original = entries
283                .get(&exact)
284                .ok_or("missing canonical numeric manifest")?;
285            if entries
286                .get(&minor)
287                .is_none_or(|e| e.sha256 != original.sha256)
288            {
289                return Err("numeric alias manifest mismatch".into());
290            }
291            for name in [&exact, &minor] {
292                let side = entries
293                    .get(&format!("{name}.sha256"))
294                    .ok_or("missing numeric alias checksum")?;
295                if checksum(&read_file(&side.source, 4096)?)? != original.sha256
296                    || !entries.contains_key(&format!("{name}.asc"))
297                {
298                    return Err("invalid numeric alias metadata triplet".into());
299                }
300            }
301            let matches = records.iter().filter(|r| r.release == full).any(|r| {
302                Toolchain::parse(&r.selection.toolchain).is_ok_and(|spec| {
303                    entries
304                        .get(&format!(
305                            "dist/{}/channel-rust-{}.toml",
306                            r.date, spec.channel
307                        ))
308                        .is_some_and(|e| e.sha256 == original.sha256)
309                })
310            });
311            if !matches {
312                return Err("numeric alias has no matching original toolchain record".into());
313            }
314        }
315    }
316    Ok(Input {
317        rustup,
318        records,
319        files: entries,
320        identity: sha256(&raw),
321    })
322}
323/// Assemble a new generation. New inputs may replace only stable/beta/nightly root
324/// metadata from `previous`; immutable path conflicts always refuse. No input is edited.
325pub fn assemble(
326    inputs: &[PathBuf],
327    previous: Option<&Path>,
328    output: &Path,
329    max_bytes: u64,
330) -> Result<PathBuf> {
331    if inputs.is_empty()
332        || inputs.len() > 32
333        || max_bytes == 0
334        || max_bytes > 100 * 1024 * 1024 * 1024
335    {
336        return Err("use 1-32 inputs and a 1 byte through 100 GiB generation budget".into());
337    }
338    let parent = output
339        .parent()
340        .filter(|p| !p.as_os_str().is_empty())
341        .unwrap_or_else(|| Path::new("."));
342    let target = parent.canonicalize()?.join(
343        output
344            .file_name()
345            .ok_or("output needs a new directory name")?,
346    );
347    for root in inputs.iter().map(PathBuf::as_path).chain(previous) {
348        if target.starts_with(root.canonicalize()?) {
349            return Err("output must not be inside an input snapshot".into());
350        }
351    }
352    let (inventory, files) = prepare(inputs, previous, max_bytes)?;
353    fs::create_dir(output)?;
354    let staging = tempfile::Builder::new()
355        .prefix(".assembling-")
356        .tempdir_in(output)?;
357    write_prepared(&inventory, &files, staging.path())?;
358    let completed = output.join("mirror");
359    if fs::symlink_metadata(&completed).is_ok() {
360        return Err("completed generation unexpectedly exists".into());
361    }
362    fs::rename(staging.path(), &completed)?;
363    Ok(completed)
364}
365
366pub(super) fn prepare(
367    inputs: &[PathBuf],
368    previous: Option<&Path>,
369    max_bytes: u64,
370) -> Result<(CollectionSnapshot, BTreeMap<String, Entry>)> {
371    if inputs.is_empty()
372        || inputs.len() > 32
373        || max_bytes == 0
374        || max_bytes > 100 * 1024 * 1024 * 1024
375    {
376        return Err("invalid collection input/budget".into());
377    }
378    let old = previous.map(read_input).transpose()?;
379    let sources: Vec<_> = inputs
380        .iter()
381        .map(|p| read_input(p))
382        .collect::<Result<_>>()?;
383    let rustup = sources[0].rustup.clone();
384    let mut files = BTreeMap::new();
385    let mut records = Vec::new();
386    let mut identities = BTreeSet::new();
387    if let Some(old) = old {
388        if old.rustup != rustup {
389            return Err("all inputs must use the same Rustup bootstrap version".into());
390        }
391        files = old.files;
392        records = old.records;
393        identities.insert(old.identity);
394    }
395    let mut incoming: BTreeMap<String, Entry> = BTreeMap::new();
396    for source in sources {
397        if source.rustup != rustup {
398            return Err("all inputs must use the same Rustup bootstrap version".into());
399        }
400        identities.insert(source.identity);
401        for record in source.records {
402            if !records.contains(&record) {
403                records.push(record);
404            }
405        }
406        for (path, entry) in source.files {
407            if let Some(prior) = incoming.get(&path) {
408                if prior.sha256 != entry.sha256 || prior.bytes != entry.bytes {
409                    return Err(format!(
410                        "conflicting new inputs at {path}; resolve a coherent set"
411                    )
412                    .into());
413                }
414            }
415            incoming.insert(path, entry);
416        }
417    }
418    for (path, entry) in incoming {
419        if let Some(prior) = files.get(&path) {
420            if (prior.sha256 != entry.sha256 || prior.bytes != entry.bytes) && !rolling(&path) {
421                return Err(format!("immutable retained path conflict: {path}").into());
422            }
423        }
424        files.insert(path, entry);
425    }
426    if records.len() > 256 || files.len() > FILE_LIMIT {
427        return Err("collection record/file limit exceeded".into());
428    }
429    let total = files.values().try_fold(0u64, |n, e| {
430        n.checked_add(e.bytes).ok_or("collection size overflow")
431    })?;
432    if total > max_bytes {
433        return Err("generation byte budget exceeded before output creation".into());
434    }
435    records.sort_by_key(|r| toml::to_string(r).unwrap_or_default());
436    let inventory = CollectionSnapshot {
437        schema: 3,
438        rustup_version: rustup,
439        toolchains: records,
440        inputs: identities.into_iter().collect(),
441        files: files
442            .iter()
443            .map(|(p, e)| SnapshotFile {
444                path: p.clone(),
445                sha256: e.sha256.clone(),
446                bytes: e.bytes,
447            })
448            .collect(),
449    };
450    let serialized = toml::to_string_pretty(&inventory)?;
451    if serialized.len() as u64 > INVENTORY_LIMIT {
452        return Err("collection inventory exceeds 1 MiB".into());
453    }
454    Ok((inventory, files))
455}
456pub(super) fn write_prepared(
457    inventory: &CollectionSnapshot,
458    files: &BTreeMap<String, Entry>,
459    staging: &Path,
460) -> Result<()> {
461    let serialized = toml::to_string_pretty(inventory)?;
462    for (path, entry) in files {
463        let target = staging.join(path);
464        fs::create_dir_all(target.parent().ok_or("missing parent")?)?;
465        let mut file = File::create_new(target)?;
466        let mut writer = crate::HashWriter {
467            file: &mut file,
468            hash: sha2::Sha256::default(),
469            bytes: 0,
470        };
471        crate::copy_bounded(File::open(&entry.source)?, &mut writer, entry.bytes)?;
472        let count = writer.bytes;
473        let hash = hex::encode(sha2::Digest::finalize(writer.hash));
474        if count != entry.bytes || hash != entry.sha256 {
475            return Err("input changed while assembling generation".into());
476        }
477        file.sync_all()?;
478    }
479    super::write_new(&staging.join("snapshot.toml"), serialized.as_bytes())?;
480    Ok(())
481}
482
483/// Reuse the full selected/collection validation boundary for publication.
484pub(crate) fn publication_files(root: &Path) -> Result<Vec<SnapshotFile>> {
485    let input = read_input(root)?;
486    Ok(input
487        .files
488        .into_iter()
489        .map(|(path, e)| SnapshotFile {
490            path,
491            bytes: e.bytes,
492            sha256: e.sha256,
493        })
494        .collect())
495}