1use crate::{Result, SnapshotFile, ARTIFACT_LIMIT};
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 fs::File,
8 io::Read,
9 path::{Path, PathBuf},
10 sync::Arc,
11 time::Duration,
12};
13
14pub struct Options {
16 pub base_url: String,
17 pub ca_file: Option<PathBuf>,
18 pub credentials: Option<(String, String)>,
19 pub allow_loopback_http: bool,
21}
22#[derive(Serialize)]
23pub struct Report {
24 pub files: usize,
25 pub remote_bytes_verified: u64,
26 pub inventory_sha256: String,
27 pub schema: u32,
28}
29fn read(path: &Path, limit: u64) -> Result<Vec<u8>> {
30 if !std::fs::metadata(path)?.is_file() {
31 return Err("publication input must be a regular file".into());
32 }
33 let f = File::open(path)?;
34 if !f.metadata()?.is_file() {
35 return Err("publication input must be a regular file".into());
36 }
37 let mut bytes = Vec::new();
38 f.take(limit + 1).read_to_end(&mut bytes)?;
39 if bytes.len() as u64 > limit {
40 return Err("publication metadata exceeds byte budget".into());
41 }
42 Ok(bytes)
43}
44fn files(root: &Path) -> Result<(u32, Vec<SnapshotFile>, String)> {
45 let directory = std::fs::symlink_metadata(root)?;
46 let inventory = std::fs::symlink_metadata(root.join("snapshot.toml"))?;
47 if !directory.is_dir()
48 || directory.file_type().is_symlink()
49 || !inventory.is_file()
50 || inventory.file_type().is_symlink()
51 {
52 return Err("publication needs a real mirror directory and inventory".into());
53 }
54 let raw = read(&root.join("snapshot.toml"), 1024 * 1024)?;
55 let doc: toml::Value = toml::from_str(std::str::from_utf8(&raw)?)?;
56 let schema = doc
57 .get("schema")
58 .and_then(toml::Value::as_integer)
59 .ok_or("missing snapshot schema")?;
60 let mut files = if schema == 1 {
61 let s: crate::Snapshot = toml::from_str(std::str::from_utf8(&raw)?)?;
62 crate::version(&s.release)?;
63 crate::version(&s.rustup_version)?;
64 if s.profile != "minimal"
65 || s.host != "x86_64-unknown-linux-gnu"
66 || s.files.is_empty()
67 || s.files.len() > 24
68 {
69 return Err("unsupported legacy inventory".into());
70 }
71 let mut names = BTreeSet::from([PathBuf::from("snapshot.toml")]);
72 let mut map = BTreeMap::new();
73 for f in &s.files {
74 crate::safe_path(&f.path)?;
75 crate::valid_hash(&f.sha256)?;
76 if f.path == "snapshot.toml"
77 || f.bytes > ARTIFACT_LIMIT
78 || map.insert(f.path.clone(), f.sha256.clone()).is_some()
79 {
80 return Err("invalid legacy inventory entry".into());
81 }
82 names.insert(PathBuf::from(&f.path));
83 }
84 crate::resume::validate_tree(root, &names)?;
85 for f in &s.files {
86 if crate::resume::hash_file(&root.join(&f.path), f.bytes)?
87 != (f.bytes, f.sha256.clone())
88 {
89 return Err("legacy inventory file changed".into());
90 }
91 }
92 let manifest = format!("dist/channel-rust-{}.toml", s.release);
93 let data = read(&root.join(&manifest), crate::METADATA_LIMIT)?;
94 if crate::checksum(&read(&root.join(format!("{manifest}.sha256")), 4096)?)?
95 != crate::sha256(&data)
96 {
97 return Err("legacy manifest checksum mismatch".into());
98 }
99 read(&root.join(format!("{manifest}.asc")), 64 * 1024)?;
100 let options = crate::Options {
101 release: s.release.clone(),
102 host: s.host.clone(),
103 rustup_version: s.rustup_version.clone(),
104 output: PathBuf::new(),
105 };
106 let mut expected = crate::plan(&data, &options)?
107 .into_iter()
108 .collect::<BTreeMap<_, _>>();
109 let bootstrap = format!("rustup/archive/{}/{}/rustup-init", s.rustup_version, s.host);
110 let bootstrap_hash =
111 crate::checksum(&read(&root.join(format!("{bootstrap}.sha256")), 4096)?)?;
112 expected.insert(bootstrap.clone(), bootstrap_hash);
113 let mut required = expected.keys().cloned().collect::<BTreeSet<_>>();
114 required.extend([
115 format!("{bootstrap}.sha256"),
116 manifest.clone(),
117 format!("{manifest}.sha256"),
118 format!("{manifest}.asc"),
119 ]);
120 if required != map.keys().cloned().collect()
121 || expected.iter().any(|(p, h)| map.get(p) != Some(h))
122 {
123 return Err("legacy inventory omits or changes required content".into());
124 }
125 s.files
126 } else if schema == 2 || schema == 3 {
127 crate::selection::collection::publication_files(root)?
128 } else {
129 return Err("unsupported publication inventory schema".into());
130 };
131 files.sort_by_key(|f| {
132 (
133 f.path.contains("/channel-rust-") || f.path == "rustup/release-stable.toml",
134 f.path.ends_with(".sha256"),
135 f.path.clone(),
136 )
137 });
138 let hash = crate::sha256(&raw);
139 files.push(SnapshotFile {
140 path: "snapshot.toml".into(),
141 sha256: hash.clone(),
142 bytes: raw.len() as u64,
143 });
144 Ok((schema as u32, files, hash))
145}
146fn base_url(options: &Options) -> Result<String> {
147 let base = options.base_url.trim_end_matches('/');
148 let (scheme, rest) = base
149 .split_once("://")
150 .ok_or("publication needs an absolute URL")?;
151 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
152 if authority.is_empty()
153 || !authority
154 .bytes()
155 .all(|c| c.is_ascii_alphanumeric() || b"-.[]:".contains(&c))
156 || path.split('/').any(|p| p == "." || p == "..")
157 || !path
158 .bytes()
159 .all(|c| c.is_ascii_alphanumeric() || b"-._/".contains(&c))
160 {
161 return Err(
162 "unsafe publication URL (no credentials, query, fragment or encoded paths)".into(),
163 );
164 }
165 let local = authority == "127.0.0.1"
166 || authority == "[::1]"
167 || authority.parse::<std::net::SocketAddr>().is_ok_and(|a| {
168 a.ip() == std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
169 || a.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
170 });
171 if scheme != "https" && !(scheme == "http" && options.allow_loopback_http && local) {
172 return Err("HTTPS required; literal loopback HTTP needs explicit test opt-in".into());
173 }
174 if scheme != "https" && (options.credentials.is_some() || options.ca_file.is_some()) {
175 return Err("credentials and CA configuration require HTTPS".into());
176 }
177 Ok(base.into())
178}
179fn authorization(user: &str, password: &str) -> Result<String> {
180 if user.is_empty() || password.is_empty() || user.contains(':') {
181 return Err("invalid publication credentials".into());
182 }
183 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
184 let input = format!("{user}:{password}");
185 let mut out = String::from("Basic ");
186 for chunk in input.as_bytes().chunks(3) {
187 let n = ((chunk[0] as u32) << 16)
188 | ((chunk.get(1).copied().unwrap_or(0) as u32) << 8)
189 | (chunk.get(2).copied().unwrap_or(0) as u32);
190 for i in 0..4 {
191 out.push(if i > chunk.len() {
192 '='
193 } else {
194 ALPHABET[((n >> (18 - i * 6)) & 63) as usize] as char
195 });
196 }
197 }
198 Ok(out)
199}
200struct Client {
201 agent: ureq::Agent,
202 base: String,
203 authorization: Option<String>,
204}
205impl Client {
206 fn request(
207 &self,
208 method: &str,
209 name: &str,
210 body: Option<&Path>,
211 len: u64,
212 ) -> Result<ureq::Response> {
213 let mut request = self.agent.request(method, &format!("{}/{name}", self.base));
214 if let Some(auth) = &self.authorization {
215 request = request.set("Authorization", auth);
216 }
217 let result = if let Some(path) = body {
218 request
219 .set("Content-Length", &len.to_string())
220 .set("Content-Type", "application/octet-stream")
221 .set("If-None-Match", "*")
222 .send(File::open(path)?)
223 } else {
224 request.call()
225 };
226 match result {
227 Ok(r) | Err(ureq::Error::Status(_, r)) => Ok(r),
228 Err(_) => {
229 Err("publication transport failed (check endpoint, TLS trust and network)".into())
230 }
231 }
232 }
233}
234pub fn publish(root: &Path, options: &Options) -> Result<Report> {
238 let base = base_url(options)?;
239 let mut builder = ureq::AgentBuilder::new()
240 .redirects(0)
241 .try_proxy_from_env(false)
242 .timeout(Duration::from_secs(180));
243 if let Some(path) = &options.ca_file {
244 use ureq::rustls::{
245 pki_types::{pem::PemObject, CertificateDer},
246 RootCertStore,
247 };
248 let raw = read(path, 4 * 1024 * 1024)?;
249 let mut roots = RootCertStore::empty();
250 let mut count = 0;
251 for cert in CertificateDer::pem_slice_iter(&raw) {
252 count += 1;
253 if count > 256 {
254 return Err("CA bundle exceeds certificate budget".into());
255 }
256 roots
257 .add(cert.map_err(|_| "invalid CA PEM")?)
258 .map_err(|_| "invalid CA certificate")?;
259 }
260 if count == 0 {
261 return Err("CA bundle has no certificates".into());
262 }
263 let config = ureq::rustls::ClientConfig::builder_with_provider(Arc::new(
264 ureq::rustls::crypto::ring::default_provider(),
265 ))
266 .with_safe_default_protocol_versions()?
267 .with_root_certificates(roots)
268 .with_no_client_auth();
269 builder = builder.tls_config(Arc::new(config));
270 }
271 let authorization = options
272 .credentials
273 .as_ref()
274 .map(|(u, p)| authorization(u, p))
275 .transpose()?;
276 let client = Client {
277 agent: builder.build(),
278 base,
279 authorization,
280 };
281 let (schema, files, inventory_sha256) = files(root)?;
282 for f in &files {
283 match client.request("HEAD", &f.path, None, 0)?.status() {
284 404 => {}
285 200..=299 => return Err("destination is not absent; no files were uploaded".into()),
286 status => {
287 return Err(format!(
288 "destination preflight refused (HTTP {status}); no files were uploaded"
289 )
290 .into())
291 }
292 }
293 }
294 let mut total = 0u64;
295 for f in &files {
296 let path = root.join(&f.path);
297 if crate::resume::hash_file(&path, f.bytes)? != (f.bytes, f.sha256.clone()) {
298 return Err("snapshot changed before upload".into());
299 }
300 let response = client.request("PUT", &f.path, Some(&path), f.bytes)?;
301 if ![200, 201, 204].contains(&response.status()) {
302 return Err(format!("upload failed (HTTP {})", response.status()).into());
303 }
304 let response = client.request("GET", &f.path, None, 0)?;
305 if response.status() != 200 {
306 return Err("remote verification GET failed".into());
307 }
308 let mut reader = response.into_reader().take(f.bytes + 1);
309 let mut hasher = Sha256::new();
310 let mut n = 0u64;
311 let mut buffer = [0u8; 64 * 1024];
312 loop {
313 let bytes = reader.read(&mut buffer)?;
314 if bytes == 0 {
315 break;
316 }
317 n += bytes as u64;
318 hasher.update(&buffer[..bytes]);
319 }
320 if n != f.bytes || hex::encode(hasher.finalize()) != f.sha256 {
321 return Err("remote bytes differ; incomplete publication must not be activated".into());
322 }
323 total = total
324 .checked_add(n)
325 .ok_or("publication byte count overflow")?;
326 }
327 Ok(Report {
328 files: files.len(),
329 remote_bytes_verified: total,
330 inventory_sha256,
331 schema,
332 })
333}
334#[cfg(test)]
335mod tests {
336 use super::*;
337 #[test]
338 fn basic_auth_known_vectors() {
339 assert_eq!(
340 authorization("Aladdin", "open sesame").unwrap(),
341 "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
342 );
343 assert_eq!(authorization("u", "p").unwrap(), "Basic dTpw");
344 assert_eq!(authorization("u", "pa").unwrap(), "Basic dTpwYQ==");
345 }
346 #[test]
347 fn endpoints_refuse_credentials_and_nonliteral_http() {
348 for base in [
349 "http://localhost/x",
350 "http://127.0.0.1.example/x",
351 "https://u:p@example.test/x",
352 "https://example.test/../x",
353 "https://example.test/x?token=secret",
354 ] {
355 assert!(base_url(&Options {
356 base_url: base.into(),
357 ca_file: None,
358 credentials: None,
359 allow_loopback_http: true
360 })
361 .is_err());
362 }
363 }
364}