1pub mod publish;
3mod resume;
4pub mod selection;
5pub use resume::Transfer;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::{
9 collections::BTreeMap,
10 error::Error,
11 fs::{self, File},
12 io::{self, Read, Write},
13 path::{Path, PathBuf},
14 time::Duration,
15};
16
17pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>;
18const ORIGIN: &str = "https://static.rust-lang.org/";
19const METADATA_LIMIT: u64 = 4 * 1024 * 1024;
20const ARTIFACT_LIMIT: u64 = 512 * 1024 * 1024;
21
22#[derive(Debug)]
23pub struct Options {
24 pub release: String,
25 pub host: String,
26 pub rustup_version: String,
27 pub output: PathBuf,
29}
30
31pub trait Source {
34 fn fetch(&self, url: &str, output: &mut dyn Write, limit: u64) -> Result<()>;
35 fn fetch_from(
39 &self,
40 url: &str,
41 output: &mut dyn Write,
42 offset: u64,
43 limit: u64,
44 ) -> Result<Transfer> {
45 if offset != 0 {
46 return Ok(Transfer::Restart);
47 }
48 self.fetch(url, output, limit)?;
49 Ok(Transfer::Complete)
50 }
51}
52
53pub struct HttpsSource(ureq::Agent);
54impl Default for HttpsSource {
55 fn default() -> Self {
56 Self(
57 ureq::AgentBuilder::new()
58 .timeout(Duration::from_secs(180))
59 .redirects(0)
60 .build(),
61 )
62 }
63}
64impl Source for HttpsSource {
65 fn fetch_from(
66 &self,
67 url: &str,
68 output: &mut dyn Write,
69 offset: u64,
70 limit: u64,
71 ) -> Result<Transfer> {
72 safe_path(
73 url.strip_prefix(ORIGIN)
74 .ok_or("source must be the official HTTPS origin")?,
75 )?;
76 resume::http_range(&self.0, url, output, offset, limit)
77 }
78 fn fetch(&self, url: &str, output: &mut dyn Write, limit: u64) -> Result<()> {
79 let path = url
80 .strip_prefix(ORIGIN)
81 .ok_or("source must be the official HTTPS origin")?;
82 safe_path(path)?;
83 let _ = writeln!(io::stderr().lock(), "Downloading {path}");
84 let response = self.0.get(url).call()?;
85 if response.status() != 200 {
86 return Err("source did not return HTTP 200".into());
87 }
88 copy_bounded(response.into_reader(), output, limit)
89 }
90}
91fn copy_bounded(input: impl Read, output: &mut dyn Write, limit: u64) -> Result<()> {
92 let count = io::copy(&mut input.take(limit + 1), output)?;
93 if count > limit {
94 return Err("download exceeds size budget".into());
95 }
96 Ok(())
97}
98
99#[derive(Serialize, Deserialize, Debug)]
100#[serde(deny_unknown_fields)]
101pub struct Snapshot {
102 pub schema: u32,
103 pub release: String,
104 pub host: String,
105 pub rustup_version: String,
106 pub profile: String,
107 pub files: Vec<SnapshotFile>,
108}
109#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
110#[serde(deny_unknown_fields)]
111pub struct SnapshotFile {
112 pub path: String,
113 pub sha256: String,
114 pub bytes: u64,
115}
116
117#[derive(Deserialize)]
118#[serde(rename_all = "kebab-case")]
119struct Manifest {
120 manifest_version: String,
121 date: String,
122 pkg: BTreeMap<String, Package>,
123 profiles: BTreeMap<String, Vec<String>>,
124 #[serde(default)]
125 renames: BTreeMap<String, Renamed>,
126}
127#[derive(Deserialize)]
128struct Renamed {
129 to: String,
130}
131#[derive(Deserialize)]
132struct Package {
133 version: String,
134 target: BTreeMap<String, Target>,
135}
136#[derive(Deserialize)]
137struct Target {
138 available: bool,
139 url: Option<String>,
140 hash: Option<String>,
141 xz_url: Option<String>,
142 xz_hash: Option<String>,
143 zst_url: Option<String>,
144 zst_hash: Option<String>,
145 #[serde(default)]
146 components: Vec<Component>,
147 #[serde(default)]
148 extensions: Vec<Component>,
149}
150#[derive(Deserialize)]
151struct Component {
152 pkg: String,
153 target: String,
154}
155
156fn version(value: &str) -> Result<()> {
157 let parts: Vec<_> = value.split('.').collect();
158 if parts.len() != 3
159 || parts
160 .iter()
161 .any(|p| p.is_empty() || p.len() > 5 || !p.bytes().all(|c| c.is_ascii_digit()))
162 {
163 return Err("expected a pinned numeric version, such as 1.90.0".into());
164 }
165 Ok(())
166}
167fn safe_path(path: &str) -> Result<()> {
168 if path.is_empty()
169 || path.len() > 512
170 || path
171 .split('/')
172 .any(|p| p.is_empty() || p == "." || p == "..")
173 || !path
174 .bytes()
175 .all(|c| c.is_ascii_alphanumeric() || b"-._/".contains(&c))
176 {
177 return Err("unsafe mirror-relative path".into());
178 }
179 Ok(())
180}
181fn valid_hash(value: &str) -> Result<()> {
182 if value.len() != 64 || !value.bytes().all(|c| c.is_ascii_hexdigit()) {
183 return Err("invalid SHA-256".into());
184 }
185 Ok(())
186}
187fn checksum(bytes: &[u8]) -> Result<String> {
188 let text = std::str::from_utf8(bytes)?;
189 let hash = text
190 .split_whitespace()
191 .next()
192 .ok_or("empty checksum file")?;
193 valid_hash(hash)?;
194 Ok(hash.to_ascii_lowercase())
195}
196fn bytes(source: &impl Source, path: &str, limit: u64) -> Result<Vec<u8>> {
197 safe_path(path)?;
198 let mut output = Vec::new();
199 source.fetch(&format!("{ORIGIN}{path}"), &mut output, limit)?;
200 if output.len() as u64 > limit {
201 return Err("source exceeded byte budget".into());
202 }
203 Ok(output)
204}
205fn sha256(bytes: &[u8]) -> String {
206 hex::encode(Sha256::digest(bytes))
207}
208
209fn plan(raw: &[u8], options: &Options) -> Result<Vec<(String, String)>> {
210 let m: Manifest = toml::from_str(std::str::from_utf8(raw)?)?;
211 if m.manifest_version != "2" {
212 return Err("unsupported upstream manifest schema".into());
213 }
214 let date = m.date.as_bytes();
215 if date.len() != 10
216 || date[4] != b'-'
217 || date[7] != b'-'
218 || date
219 .iter()
220 .enumerate()
221 .any(|(i, c)| i != 4 && i != 7 && !c.is_ascii_digit())
222 {
223 return Err("invalid manifest date".into());
224 }
225 let rust = m.pkg.get("rust").ok_or("missing rust metapackage")?;
226 if rust.version.split_whitespace().next() != Some(options.release.as_str()) {
227 return Err("manifest release does not match request".into());
228 }
229 let host = rust
230 .target
231 .get(&options.host)
232 .filter(|h| h.available)
233 .ok_or("host unavailable")?;
234 let profile = m.profiles.get("minimal").ok_or("missing minimal profile")?;
235 let mut artifacts = BTreeMap::new();
236 let mut selected = Vec::new();
237 for component in host
238 .components
239 .iter()
240 .chain(&host.extensions)
241 .filter(|c| profile.contains(&c.pkg) && (c.target == options.host || c.target == "*"))
242 {
243 let package = m
244 .pkg
245 .get(&component.pkg)
246 .ok_or("dangling profile package")?;
247 let target = package
248 .target
249 .get(&component.target)
250 .or_else(|| package.target.get("*"))
251 .ok_or("dangling component target")?;
252 if !target.available {
253 return Err("selected component unavailable".into());
254 }
255 let (url, hash) = if let Some(url) = &target.zst_url {
256 (url, target.zst_hash.as_ref())
257 } else if let Some(url) = &target.xz_url {
258 (url, target.xz_hash.as_ref())
259 } else {
260 (
261 target.url.as_ref().ok_or("component has no archive URL")?,
262 target.hash.as_ref(),
263 )
264 };
265 let hash = hash.ok_or("archive URL has no matching checksum")?;
266 valid_hash(hash)?;
267 let path = url.strip_prefix(ORIGIN).ok_or("foreign artifact origin")?;
268 safe_path(path)?;
269 if !path.starts_with(&format!("dist/{}/", m.date)) {
270 return Err("artifact outside selected release directory".into());
271 }
272 if let Some(previous) = artifacts.insert(path.to_string(), hash.to_ascii_lowercase()) {
273 if previous != hash.to_ascii_lowercase() {
274 return Err("conflicting artifact checksums".into());
275 }
276 }
277 selected.push(component.pkg.as_str());
278 }
279 for required in ["rustc", "cargo", "rust-std"] {
280 if !selected.contains(&required) {
281 return Err(format!("minimal profile is missing {required}").into());
282 }
283 }
284 if artifacts.len() > 16 {
285 return Err("minimal profile exceeds artifact budget".into());
286 }
287 Ok(artifacts.into_iter().collect())
288}
289
290struct HashWriter<'a> {
291 file: &'a mut File,
292 hash: Sha256,
293 bytes: u64,
294}
295impl Write for HashWriter<'_> {
296 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
297 let count = self.file.write(data)?;
298 self.hash.update(&data[..count]);
299 self.bytes += count as u64;
300 Ok(count)
301 }
302 fn flush(&mut self) -> io::Result<()> {
303 self.file.flush()
304 }
305}
306fn save(root: &Path, path: &str, data: &[u8], files: &mut Vec<SnapshotFile>) -> Result<()> {
307 safe_path(path)?;
308 let destination = root.join(path);
309 fs::create_dir_all(destination.parent().ok_or("missing parent")?)?;
310 let mut file = File::options()
311 .create_new(true)
312 .write(true)
313 .open(&destination)?;
314 file.write_all(data)?;
315 file.sync_all()?;
316 files.push(SnapshotFile {
317 path: path.into(),
318 sha256: sha256(data),
319 bytes: data.len() as u64,
320 });
321 Ok(())
322}
323fn download(
324 root: &Path,
325 source: &impl Source,
326 path: &str,
327 expected: &str,
328 files: &mut Vec<SnapshotFile>,
329) -> Result<()> {
330 safe_path(path)?;
331 valid_hash(expected)?;
332 let destination = root.join(path);
333 fs::create_dir_all(destination.parent().ok_or("missing parent")?)?;
334 let mut file = File::options()
335 .create_new(true)
336 .write(true)
337 .open(&destination)?;
338 let mut writer = HashWriter {
339 file: &mut file,
340 hash: Sha256::new(),
341 bytes: 0,
342 };
343 source.fetch(&format!("{ORIGIN}{path}"), &mut writer, ARTIFACT_LIMIT)?;
344 let count = writer.bytes;
345 let actual = hex::encode(writer.hash.finalize());
346 if count > ARTIFACT_LIMIT || actual != expected.to_ascii_lowercase() {
347 return Err(format!("size/checksum verification failed: {path}").into());
348 }
349 file.sync_all()?;
350 files.push(SnapshotFile {
351 path: path.into(),
352 sha256: actual,
353 bytes: count,
354 });
355 Ok(())
356}
357
358pub fn prepare(options: &Options, source: &impl Source) -> Result<PathBuf> {
363 version(&options.release)?;
364 version(&options.rustup_version)?;
365 if options.host != "x86_64-unknown-linux-gnu" {
367 return Err("this milestone supports only x86_64-unknown-linux-gnu".into());
368 }
369 if options.output.try_exists()? {
370 return Err("output already exists; choose a new snapshot directory".into());
371 }
372 let manifest_path = format!("dist/channel-rust-{}.toml", options.release);
373 let upstream_hash = bytes(source, &format!("{manifest_path}.sha256"), 4096)?;
374 let raw = bytes(source, &manifest_path, METADATA_LIMIT)?;
375 if sha256(&raw) != checksum(&upstream_hash)? {
376 return Err("upstream manifest checksum mismatch".into());
377 }
378 let artifacts = plan(&raw, options)?;
379 let signature = bytes(source, &format!("{manifest_path}.asc"), 64 * 1024)?;
380 let bootstrap = format!(
381 "rustup/archive/{}/{}/rustup-init",
382 options.rustup_version, options.host
383 );
384 let bootstrap_hash = bytes(source, &format!("{bootstrap}.sha256"), 4096)?;
385 let expected_bootstrap = checksum(&bootstrap_hash)?;
386 fs::create_dir(&options.output)?;
387 let staging = tempfile::Builder::new()
388 .prefix(".building-")
389 .tempdir_in(&options.output)?;
390 let mut files = Vec::new();
391 for (path, hash) in artifacts {
392 download(staging.path(), source, &path, &hash, &mut files)?;
393 }
394 download(
395 staging.path(),
396 source,
397 &bootstrap,
398 &expected_bootstrap,
399 &mut files,
400 )?;
401 save(
402 staging.path(),
403 &format!("{bootstrap}.sha256"),
404 &bootstrap_hash,
405 &mut files,
406 )?;
407 save(staging.path(), &manifest_path, &raw, &mut files)?;
408 save(
409 staging.path(),
410 &format!("{manifest_path}.sha256"),
411 &upstream_hash,
412 &mut files,
413 )?;
414 save(
415 staging.path(),
416 &format!("{manifest_path}.asc"),
417 &signature,
418 &mut files,
419 )?;
420 files.sort_by(|a, b| a.path.cmp(&b.path));
421 let snapshot = Snapshot {
422 schema: 1,
423 release: options.release.clone(),
424 host: options.host.clone(),
425 rustup_version: options.rustup_version.clone(),
426 profile: "minimal".into(),
427 files,
428 };
429 let inventory = toml::to_string_pretty(&snapshot)?;
430 let mut file = File::options()
431 .create_new(true)
432 .write(true)
433 .open(staging.path().join("snapshot.toml"))?;
434 file.write_all(inventory.as_bytes())?;
435 file.sync_all()?;
436 drop(file); let completed = options.output.join("mirror");
438 if completed.try_exists()? {
440 return Err("completed tree unexpectedly exists".into());
441 }
442 fs::rename(staging.path(), &completed)?;
443 Ok(completed)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 #[test]
450 fn paths_reject_traversal_origins_and_encoding() {
451 for value in [
452 "../file",
453 "/file",
454 "a/../b",
455 "a\\b",
456 "a//b",
457 "a/%2e%2e/b",
458 "a?query",
459 "a:stream",
460 ] {
461 assert!(safe_path(value).is_err(), "{value}");
462 }
463 assert!(safe_path("dist/2025-09-18/rustc-1.90.0.tar.xz").is_ok());
464 }
465 #[test]
466 fn versions_are_pinned_and_bounded() {
467 for value in [
468 "stable",
469 "nightly",
470 "../1.90.0",
471 "1.90",
472 "1.90.0-beta",
473 "123456.0.0",
474 ] {
475 assert!(version(value).is_err());
476 }
477 assert!(version("1.90.0").is_ok());
478 }
479 #[test]
480 fn bounded_download_refuses_excess() {
481 let mut output = Vec::new();
482 assert!(copy_bounded(&b"12345"[..], &mut output, 4).is_err());
483 assert!(copy_bounded(&b"1234"[..], &mut Vec::new(), 4).is_ok());
484 }
485 #[test]
486 fn checksum_requires_full_hex() {
487 assert!(checksum(b"deadbeef file").is_err());
488 assert_eq!(
489 checksum(format!("{} file\n", "A".repeat(64)).as_bytes()).unwrap(),
490 "a".repeat(64)
491 );
492 }
493}