Skip to main content

rust_mirror/
publish.rs

1//! Verified, no-overwrite publication to a fresh Nexus Raw/static HTTP prefix.
2use 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
14/// Credentials are explicit, never read implicitly by the library or included in Debug output.
15pub struct Options {
16    pub base_url: String,
17    pub ca_file: Option<PathBuf>,
18    pub credentials: Option<(String, String)>,
19    /// Test-only literal IPv4/IPv6 loopback. Credentials still require HTTPS.
20    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}
234/// Validate all local content and every destination's absence before the first PUT.
235/// Send the inventory marker last, verify every remote byte, and never retry writes
236/// or activate a server/group. Requires an exclusive publisher and no-redeploy server.
237pub 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}