1pub 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 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 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}
671pub fn fetch(plan_dir: &Path, output: &Path, source: &impl Source) -> Result<PathBuf> {
673 fetch_with_resume(plan_dir, output, source, false, None)
674}
675pub 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}
824pub 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}
914pub 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}