1use 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
13pub 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 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 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, ¤t, n, hash, &mut total, max_bytes)?;
235 let path = root.join(¤t);
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}