1use 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 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 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 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(¤t)
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}
323pub 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
483pub(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}