rust-mirror 0.1.0

Prepare pinned Rust toolchain snapshots for offline static hosting
Documentation
//! Verified, no-overwrite publication to a fresh Nexus Raw/static HTTP prefix.
use crate::{Result, SnapshotFile, ARTIFACT_LIMIT};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
    collections::{BTreeMap, BTreeSet},
    fs::File,
    io::Read,
    path::{Path, PathBuf},
    sync::Arc,
    time::Duration,
};

/// Credentials are explicit, never read implicitly by the library or included in Debug output.
pub struct Options {
    pub base_url: String,
    pub ca_file: Option<PathBuf>,
    pub credentials: Option<(String, String)>,
    /// Test-only literal IPv4/IPv6 loopback. Credentials still require HTTPS.
    pub allow_loopback_http: bool,
}
#[derive(Serialize)]
pub struct Report {
    pub files: usize,
    pub remote_bytes_verified: u64,
    pub inventory_sha256: String,
    pub schema: u32,
}
fn read(path: &Path, limit: u64) -> Result<Vec<u8>> {
    if !std::fs::metadata(path)?.is_file() {
        return Err("publication input must be a regular file".into());
    }
    let f = File::open(path)?;
    if !f.metadata()?.is_file() {
        return Err("publication input must be a regular file".into());
    }
    let mut bytes = Vec::new();
    f.take(limit + 1).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > limit {
        return Err("publication metadata exceeds byte budget".into());
    }
    Ok(bytes)
}
fn files(root: &Path) -> Result<(u32, Vec<SnapshotFile>, String)> {
    let directory = std::fs::symlink_metadata(root)?;
    let inventory = std::fs::symlink_metadata(root.join("snapshot.toml"))?;
    if !directory.is_dir()
        || directory.file_type().is_symlink()
        || !inventory.is_file()
        || inventory.file_type().is_symlink()
    {
        return Err("publication needs a real mirror directory and inventory".into());
    }
    let raw = read(&root.join("snapshot.toml"), 1024 * 1024)?;
    let doc: toml::Value = toml::from_str(std::str::from_utf8(&raw)?)?;
    let schema = doc
        .get("schema")
        .and_then(toml::Value::as_integer)
        .ok_or("missing snapshot schema")?;
    let mut files = if schema == 1 {
        let s: crate::Snapshot = toml::from_str(std::str::from_utf8(&raw)?)?;
        crate::version(&s.release)?;
        crate::version(&s.rustup_version)?;
        if s.profile != "minimal"
            || s.host != "x86_64-unknown-linux-gnu"
            || s.files.is_empty()
            || s.files.len() > 24
        {
            return Err("unsupported legacy inventory".into());
        }
        let mut names = BTreeSet::from([PathBuf::from("snapshot.toml")]);
        let mut map = BTreeMap::new();
        for f in &s.files {
            crate::safe_path(&f.path)?;
            crate::valid_hash(&f.sha256)?;
            if f.path == "snapshot.toml"
                || f.bytes > ARTIFACT_LIMIT
                || map.insert(f.path.clone(), f.sha256.clone()).is_some()
            {
                return Err("invalid legacy inventory entry".into());
            }
            names.insert(PathBuf::from(&f.path));
        }
        crate::resume::validate_tree(root, &names)?;
        for f in &s.files {
            if crate::resume::hash_file(&root.join(&f.path), f.bytes)?
                != (f.bytes, f.sha256.clone())
            {
                return Err("legacy inventory file changed".into());
            }
        }
        let manifest = format!("dist/channel-rust-{}.toml", s.release);
        let data = read(&root.join(&manifest), crate::METADATA_LIMIT)?;
        if crate::checksum(&read(&root.join(format!("{manifest}.sha256")), 4096)?)?
            != crate::sha256(&data)
        {
            return Err("legacy manifest checksum mismatch".into());
        }
        read(&root.join(format!("{manifest}.asc")), 64 * 1024)?;
        let options = crate::Options {
            release: s.release.clone(),
            host: s.host.clone(),
            rustup_version: s.rustup_version.clone(),
            output: PathBuf::new(),
        };
        let mut expected = crate::plan(&data, &options)?
            .into_iter()
            .collect::<BTreeMap<_, _>>();
        let bootstrap = format!("rustup/archive/{}/{}/rustup-init", s.rustup_version, s.host);
        let bootstrap_hash =
            crate::checksum(&read(&root.join(format!("{bootstrap}.sha256")), 4096)?)?;
        expected.insert(bootstrap.clone(), bootstrap_hash);
        let mut required = expected.keys().cloned().collect::<BTreeSet<_>>();
        required.extend([
            format!("{bootstrap}.sha256"),
            manifest.clone(),
            format!("{manifest}.sha256"),
            format!("{manifest}.asc"),
        ]);
        if required != map.keys().cloned().collect()
            || expected.iter().any(|(p, h)| map.get(p) != Some(h))
        {
            return Err("legacy inventory omits or changes required content".into());
        }
        s.files
    } else if schema == 2 || schema == 3 {
        crate::selection::collection::publication_files(root)?
    } else {
        return Err("unsupported publication inventory schema".into());
    };
    files.sort_by_key(|f| {
        (
            f.path.contains("/channel-rust-") || f.path == "rustup/release-stable.toml",
            f.path.ends_with(".sha256"),
            f.path.clone(),
        )
    });
    let hash = crate::sha256(&raw);
    files.push(SnapshotFile {
        path: "snapshot.toml".into(),
        sha256: hash.clone(),
        bytes: raw.len() as u64,
    });
    Ok((schema as u32, files, hash))
}
fn base_url(options: &Options) -> Result<String> {
    let base = options.base_url.trim_end_matches('/');
    let (scheme, rest) = base
        .split_once("://")
        .ok_or("publication needs an absolute URL")?;
    let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
    if authority.is_empty()
        || !authority
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || b"-.[]:".contains(&c))
        || path.split('/').any(|p| p == "." || p == "..")
        || !path
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || b"-._/".contains(&c))
    {
        return Err(
            "unsafe publication URL (no credentials, query, fragment or encoded paths)".into(),
        );
    }
    let local = authority == "127.0.0.1"
        || authority == "[::1]"
        || authority.parse::<std::net::SocketAddr>().is_ok_and(|a| {
            a.ip() == std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
                || a.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
        });
    if scheme != "https" && !(scheme == "http" && options.allow_loopback_http && local) {
        return Err("HTTPS required; literal loopback HTTP needs explicit test opt-in".into());
    }
    if scheme != "https" && (options.credentials.is_some() || options.ca_file.is_some()) {
        return Err("credentials and CA configuration require HTTPS".into());
    }
    Ok(base.into())
}
fn authorization(user: &str, password: &str) -> Result<String> {
    if user.is_empty() || password.is_empty() || user.contains(':') {
        return Err("invalid publication credentials".into());
    }
    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let input = format!("{user}:{password}");
    let mut out = String::from("Basic ");
    for chunk in input.as_bytes().chunks(3) {
        let n = ((chunk[0] as u32) << 16)
            | ((chunk.get(1).copied().unwrap_or(0) as u32) << 8)
            | (chunk.get(2).copied().unwrap_or(0) as u32);
        for i in 0..4 {
            out.push(if i > chunk.len() {
                '='
            } else {
                ALPHABET[((n >> (18 - i * 6)) & 63) as usize] as char
            });
        }
    }
    Ok(out)
}
struct Client {
    agent: ureq::Agent,
    base: String,
    authorization: Option<String>,
}
impl Client {
    fn request(
        &self,
        method: &str,
        name: &str,
        body: Option<&Path>,
        len: u64,
    ) -> Result<ureq::Response> {
        let mut request = self.agent.request(method, &format!("{}/{name}", self.base));
        if let Some(auth) = &self.authorization {
            request = request.set("Authorization", auth);
        }
        let result = if let Some(path) = body {
            request
                .set("Content-Length", &len.to_string())
                .set("Content-Type", "application/octet-stream")
                .set("If-None-Match", "*")
                .send(File::open(path)?)
        } else {
            request.call()
        };
        match result {
            Ok(r) | Err(ureq::Error::Status(_, r)) => Ok(r),
            Err(_) => {
                Err("publication transport failed (check endpoint, TLS trust and network)".into())
            }
        }
    }
}
/// Validate all local content and every destination's absence before the first PUT.
/// Send the inventory marker last, verify every remote byte, and never retry writes
/// or activate a server/group. Requires an exclusive publisher and no-redeploy server.
pub fn publish(root: &Path, options: &Options) -> Result<Report> {
    let base = base_url(options)?;
    let mut builder = ureq::AgentBuilder::new()
        .redirects(0)
        .try_proxy_from_env(false)
        .timeout(Duration::from_secs(180));
    if let Some(path) = &options.ca_file {
        use ureq::rustls::{
            pki_types::{pem::PemObject, CertificateDer},
            RootCertStore,
        };
        let raw = read(path, 4 * 1024 * 1024)?;
        let mut roots = RootCertStore::empty();
        let mut count = 0;
        for cert in CertificateDer::pem_slice_iter(&raw) {
            count += 1;
            if count > 256 {
                return Err("CA bundle exceeds certificate budget".into());
            }
            roots
                .add(cert.map_err(|_| "invalid CA PEM")?)
                .map_err(|_| "invalid CA certificate")?;
        }
        if count == 0 {
            return Err("CA bundle has no certificates".into());
        }
        let config = ureq::rustls::ClientConfig::builder_with_provider(Arc::new(
            ureq::rustls::crypto::ring::default_provider(),
        ))
        .with_safe_default_protocol_versions()?
        .with_root_certificates(roots)
        .with_no_client_auth();
        builder = builder.tls_config(Arc::new(config));
    }
    let authorization = options
        .credentials
        .as_ref()
        .map(|(u, p)| authorization(u, p))
        .transpose()?;
    let client = Client {
        agent: builder.build(),
        base,
        authorization,
    };
    let (schema, files, inventory_sha256) = files(root)?;
    for f in &files {
        match client.request("HEAD", &f.path, None, 0)?.status() {
            404 => {}
            200..=299 => return Err("destination is not absent; no files were uploaded".into()),
            status => {
                return Err(format!(
                    "destination preflight refused (HTTP {status}); no files were uploaded"
                )
                .into())
            }
        }
    }
    let mut total = 0u64;
    for f in &files {
        let path = root.join(&f.path);
        if crate::resume::hash_file(&path, f.bytes)? != (f.bytes, f.sha256.clone()) {
            return Err("snapshot changed before upload".into());
        }
        let response = client.request("PUT", &f.path, Some(&path), f.bytes)?;
        if ![200, 201, 204].contains(&response.status()) {
            return Err(format!("upload failed (HTTP {})", response.status()).into());
        }
        let response = client.request("GET", &f.path, None, 0)?;
        if response.status() != 200 {
            return Err("remote verification GET failed".into());
        }
        let mut reader = response.into_reader().take(f.bytes + 1);
        let mut hasher = Sha256::new();
        let mut n = 0u64;
        let mut buffer = [0u8; 64 * 1024];
        loop {
            let bytes = reader.read(&mut buffer)?;
            if bytes == 0 {
                break;
            }
            n += bytes as u64;
            hasher.update(&buffer[..bytes]);
        }
        if n != f.bytes || hex::encode(hasher.finalize()) != f.sha256 {
            return Err("remote bytes differ; incomplete publication must not be activated".into());
        }
        total = total
            .checked_add(n)
            .ok_or("publication byte count overflow")?;
    }
    Ok(Report {
        files: files.len(),
        remote_bytes_verified: total,
        inventory_sha256,
        schema,
    })
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn basic_auth_known_vectors() {
        assert_eq!(
            authorization("Aladdin", "open sesame").unwrap(),
            "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
        );
        assert_eq!(authorization("u", "p").unwrap(), "Basic dTpw");
        assert_eq!(authorization("u", "pa").unwrap(), "Basic dTpwYQ==");
    }
    #[test]
    fn endpoints_refuse_credentials_and_nonliteral_http() {
        for base in [
            "http://localhost/x",
            "http://127.0.0.1.example/x",
            "https://u:p@example.test/x",
            "https://example.test/../x",
            "https://example.test/x?token=secret",
        ] {
            assert!(base_url(&Options {
                base_url: base.into(),
                ca_file: None,
                credentials: None,
                allow_loopback_http: true
            })
            .is_err());
        }
    }
}