Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-ops/src/pypi.rs

//! **`cargo ops publish-pypi`** — upload the built wheels (and sdists) to PyPI or
//! TestPyPI, in Rust: the warehouse **legacy upload API**, the same one `twine` speaks.
//!
//! ```text
//! cargo ops publish-pypi --dry-run                      # check both releases, send nothing
//! cargo ops publish-pypi --repository testpypi          # upload to test.pypi.org
//! cargo ops publish-pypi --repository pypi <files…>     # upload to pypi.org
//! ```
//!
//! No python and no `twine` on the host (the LAW): this module reads the wheel's own
//! `METADATA` (or the sdist's `PKG-INFO`), checks it the way PyPI will, and POSTs
//! `multipart/form-data` to `https://upload.pypi.org/legacy/` (or
//! `https://test.pypi.org/legacy/`) with the fields warehouse's upload form reads:
//! `:action=file_upload`, `protocol_version=1`, `filetype`, `pyversion`, the core
//! metadata fields, `sha256_digest` + `blake2_256_digest` of the file, and the file
//! itself as `content`.
//!
//! **The token.** `PYPI_API_TOKEN` (pypi) or `TEST_PYPI_API_TOKEN` (testpypi) — or, when
//! that is unset, `~/.pypirc`'s `[pypi]` / `[testpypi]` section (`username = __token__`,
//! `password = pypi-…`), twine's order — sent as HTTP Basic auth with the username
//! `__token__`, PyPI's API-token convention. The
//! token is held in [`Token`], whose `Debug` prints `***` and which has no `Display`;
//! every message this module builds from a server reply passes through [`redact`]
//! first. It is never printed.
//!
//! **`--dry-run` is the `twine check` equivalent and more**, and it needs no token:
//! the metadata's required fields and their shapes (name, PEP 440 version, summary on
//! one line, filename ↔ metadata agreement), the readme rendered as its DECLARED
//! content type (markdown through `pulldown-cmark`; a readme PyPI would show as a
//! wall of raw text, or with relative links that 404 on pypi.org, is refused), the
//! classifiers against PyPI's own list, and whether this version is ALREADY on the
//! index (a re-upload is refused by PyPI forever — a file name can never be reused).
//! Then it prints exactly what would be sent, minus the file bytes.

use std::fmt;
use std::io::Read;
use std::path::{Path, PathBuf};

use base64::Engine as _;
use blake2::digest::consts::U32;
use blake2::{Blake2b, Digest as _};

// ─────────────────────────────────────────────────────────────────────────────
// Repositories and the token
// ─────────────────────────────────────────────────────────────────────────────

/// Where an upload goes. `Custom` exists for the mock-server tests (and a private
/// warehouse); the two real ones are spelled once, here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Repository {
    /// pypi.org.
    Pypi,
    /// test.pypi.org — the rehearsal index.
    TestPypi,
    /// Any warehouse: its legacy upload URL and its JSON API base (`<base>/pypi/...`).
    Custom { upload_url: String, api_base: String, token_env: String },
}

impl Repository {
    /// `pypi` / `testpypi`, as `--repository` takes them.
    pub fn parse(name: &str) -> Result<Self, String> {
        match name {
            "pypi" => Ok(Repository::Pypi),
            "testpypi" => Ok(Repository::TestPypi),
            other => Err(format!("unknown repository `{other}` — `pypi` or `testpypi`")),
        }
    }

    /// The legacy upload endpoint. The trailing slash matters: without it warehouse
    /// redirects, and a redirected POST arrives as a GET.
    #[must_use]
    pub fn upload_url(&self) -> String {
        match self {
            Repository::Pypi => "https://upload.pypi.org/legacy/".to_string(),
            Repository::TestPypi => "https://test.pypi.org/legacy/".to_string(),
            Repository::Custom { upload_url, .. } => upload_url.clone(),
        }
    }

    /// The JSON API base the "already on the index?" question goes to.
    #[must_use]
    pub fn api_base(&self) -> String {
        match self {
            Repository::Pypi => "https://pypi.org".to_string(),
            Repository::TestPypi => "https://test.pypi.org".to_string(),
            Repository::Custom { api_base, .. } => api_base.clone(),
        }
    }

    /// The environment variable the token is read from.
    #[must_use]
    pub fn token_env(&self) -> String {
        match self {
            Repository::Pypi => "PYPI_API_TOKEN".to_string(),
            Repository::TestPypi => "TEST_PYPI_API_TOKEN".to_string(),
            Repository::Custom { token_env, .. } => token_env.clone(),
        }
    }

    /// The `~/.pypirc` section twine would read for this index.
    #[must_use]
    pub fn pypirc_section(&self) -> String {
        match self {
            Repository::Pypi => "pypi".to_string(),
            Repository::TestPypi => "testpypi".to_string(),
            Repository::Custom { token_env, .. } => token_env.to_ascii_lowercase(),
        }
    }

    /// A short name for messages.
    #[must_use]
    pub fn label(&self) -> String {
        match self {
            Repository::Pypi => "pypi".to_string(),
            Repository::TestPypi => "testpypi".to_string(),
            Repository::Custom { upload_url, .. } => upload_url.clone(),
        }
    }
}

/// An API token. Deliberately NOT `Display`, and `Debug` prints `***`: the type
/// cannot be formatted into a message by accident.
#[derive(Clone)]
pub struct Token(String);

impl Token {
    /// Wrap a token. Refuses an empty one rather than sending `__token__:` and
    /// getting a 403 that reads like a bad token.
    pub fn new(secret: String) -> Result<Self, String> {
        let s = secret.trim().to_string();
        if s.is_empty() {
            return Err("the API token is empty".to_string());
        }
        Ok(Token(s))
    }

    /// From the repository's environment variable ONLY.
    pub fn from_env(repo: &Repository) -> Result<Self, String> {
        let var = repo.token_env();
        match std::env::var(&var) {
            Ok(v) => Token::new(v).map_err(|e| format!("${var}: {e}")),
            Err(_) => Err(format!(
                "${var} is not set — create an API token at {}/manage/account/token/ and export it \
                 (it is sent as HTTP Basic auth, user `__token__`, and never printed)",
                repo.api_base()
            )),
        }
    }

    /// **Where an upload's token comes from**, in twine's order: the environment
    /// variable first (`PYPI_API_TOKEN` / `TEST_PYPI_API_TOKEN`), then `~/.pypirc`'s
    /// section for the repository (`[pypi]` / `[testpypi]`, `username = __token__`,
    /// `password = pypi-…`). Returns the token and a description of its SOURCE — never
    /// of its value.
    pub fn resolve(repo: &Repository) -> Result<(Self, String), String> {
        let var = repo.token_env();
        if std::env::var(&var).map(|v| !v.trim().is_empty()).unwrap_or(false) {
            return Ok((Token::from_env(repo)?, format!("${var}")));
        }
        let rc = pypirc_path();
        match read_pypirc(&rc, &repo.pypirc_section())? {
            Some(t) => Ok((t, format!("{} [{}]", rc.display(), repo.pypirc_section()))),
            None => Err(format!(
                "no token for {}: ${var} is not set and {} has no [{}] section with \
                 `username = __token__` + `password` — create an API token at {}/manage/account/token/",
                repo.label(),
                rc.display(),
                repo.pypirc_section(),
                repo.api_base()
            )),
        }
    }

    /// The `Authorization` header value.
    fn basic_auth(&self) -> String {
        let raw = format!("__token__:{}", self.0);
        format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(raw))
    }

    /// The secret, for [`redact`] only.
    fn secret(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for Token {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Token(***)")
    }
}

/// `~/.pypirc` (`$HOME/.pypirc`), the file twine reads.
#[must_use]
pub fn pypirc_path() -> PathBuf {
    std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default().join(".pypirc")
}

/// Read one section's token out of a `.pypirc` (INI: `[section]`, `key = value`,
/// `#`/`;` comments). `Ok(None)` when the file or the section or its `password` is
/// absent. A `username` other than `__token__` is REFUSED: PyPI accepts only API tokens
/// for uploads, and a password sent as one would be a credential leak for nothing.
/// The value is never put into a message.
pub fn read_pypirc(path: &Path, section: &str) -> Result<Option<Token>, String> {
    let Ok(text) = std::fs::read_to_string(path) else { return Ok(None) };
    let mut in_section = false;
    let (mut user, mut password) = (None::<String>, None::<String>);
    for line in text.lines() {
        let t = line.trim();
        if t.is_empty() || t.starts_with('#') || t.starts_with(';') {
            continue;
        }
        if t.starts_with('[') && t.ends_with(']') {
            in_section = t[1..t.len() - 1].trim() == section;
            continue;
        }
        if !in_section {
            continue;
        }
        if let Some((k, v)) = t.split_once(['=', ':']) {
            match k.trim() {
                "username" => user = Some(v.trim().to_string()),
                "password" => password = Some(v.trim().to_string()),
                _ => {}
            }
        }
    }
    let Some(pw) = password else { return Ok(None) };
    match user.as_deref() {
        None | Some("__token__") => Token::new(pw).map(Some).map_err(|e| format!("{} [{section}]: {e}", path.display())),
        Some(_) => Err(format!(
            "{} [{section}]: the username is not `__token__` — PyPI uploads take an API token only",
            path.display()
        )),
    }
}

/// Remove the token — raw, and as its Basic-auth encoding — from a message, and any
/// `user:password@` a URL in it carries ([`redact_url`]).
#[must_use]
pub fn redact(message: &str, token: &Token) -> String {
    let mut out = message.replace(token.secret(), "***");
    let basic = token.basic_auth();
    let b64 = basic.trim_start_matches("Basic ");
    out = out.replace(b64, "***");
    redact_url(&out)
}

/// Replace the userinfo (`user:password@`) of every URL in a message with `***@`.
///
/// (2026-10-03, finish-bump: ureq 2 -> 3) A `Custom` repository's URL may carry
/// credentials in its userinfo — ureq sends them as HTTP Basic auth, as ureq 2 did —
/// and every error this module formats names the URL, as ureq's own error texts may
/// (`bad uri: …`). So the HTTP paths format the URL through this BEFORE it lands in a
/// message, and pass ureq's error text through it too.
#[must_use]
pub fn redact_url(message: &str) -> String {
    let mut out = String::with_capacity(message.len());
    let mut rest = message;
    while let Some(i) = rest.find("://") {
        let (head, tail) = rest.split_at(i + 3);
        out.push_str(head);
        // The authority ends at the path, the query, the fragment, or the text.
        let end = tail
            .find(|c: char| matches!(c, '/' | '?' | '#' | '`' | '"' | '\'' | '<' | '>') || c.is_whitespace())
            .unwrap_or(tail.len());
        let authority = &tail[..end];
        match authority.rfind('@') {
            Some(at) => {
                out.push_str("***@");
                out.push_str(&authority[at + 1..]);
            }
            None => out.push_str(authority),
        }
        rest = &tail[end..];
    }
    out.push_str(rest);
    out
}

// ─────────────────────────────────────────────────────────────────────────────
// Core metadata (METADATA / PKG-INFO)
// ─────────────────────────────────────────────────────────────────────────────

/// Core metadata as the file spells it: an RFC 822-style header block (repeatable
/// fields, continuation lines) and the description as the BODY after a blank line
/// (or in a `Description:` header, metadata < 2.1 style).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Metadata {
    /// `(field, value)` in file order; field names as written.
    pub fields: Vec<(String, String)>,
    /// The message body (the long description), if any.
    pub body: String,
}

impl Metadata {
    /// Parse a METADATA / PKG-INFO text.
    #[must_use]
    pub fn parse(text: &str) -> Self {
        let text = text.replace("\r\n", "\n");
        let (head, body) = match text.find("\n\n") {
            Some(i) => (&text[..i], text[i + 2..].to_string()),
            None => (text.as_str(), String::new()),
        };
        let mut fields: Vec<(String, String)> = Vec::new();
        for line in head.lines() {
            if (line.starts_with(' ') || line.starts_with('\t')) && !fields.is_empty() {
                // A continuation line (multi-line Description / License).
                let last = fields.last_mut().unwrap();
                last.1.push('\n');
                last.1.push_str(line.trim_start_matches([' ', '\t']).trim_start_matches('|'));
                continue;
            }
            if let Some((k, v)) = line.split_once(':') {
                fields.push((k.trim().to_string(), v.trim().to_string()));
            }
        }
        Metadata { fields, body }
    }

    /// The first value of a field (case-insensitive name).
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&str> {
        self.fields.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.as_str())
    }

    /// Every value of a repeatable field.
    #[must_use]
    pub fn all(&self, key: &str) -> Vec<&str> {
        self.fields.iter().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.as_str()).collect()
    }

    /// The long description: the body, else a `Description:` header.
    #[must_use]
    pub fn description(&self) -> Option<&str> {
        if !self.body.trim().is_empty() {
            return Some(self.body.as_str());
        }
        self.get("Description").filter(|d| !d.trim().is_empty())
    }
}

/// PEP 503 normalisation (`re.sub(r"[-_.]+", "-", name).lower()`) — holger's, the one
/// writer of it in this crate.
pub use crate::holger::normalize_name;

/// A distribution file, read and ready to check or send.
#[derive(Debug, Clone)]
pub struct DistFile {
    /// The file on disk.
    pub path: PathBuf,
    /// Its name.
    pub file_name: String,
    /// `bdist_wheel` or `sdist`.
    pub filetype: &'static str,
    /// `py3` (the wheel's python tag) or `source`.
    pub pyversion: String,
    /// The distribution and version the FILENAME says.
    pub name_in_filename: String,
    pub version_in_filename: String,
    /// The metadata inside the file.
    pub metadata: Metadata,
    /// The bytes.
    pub bytes: Vec<u8>,
}

impl DistFile {
    /// Lowercase hex sha256 of the file.
    #[must_use]
    pub fn sha256_hex(&self) -> String {
        crate::pip::sha256_hex(&self.bytes)
    }

    /// Lowercase hex BLAKE2b-256 of the file (what warehouse stores as `blake2_256`).
    #[must_use]
    pub fn blake2_256_hex(&self) -> String {
        let mut h = Blake2b::<U32>::new();
        h.update(&self.bytes);
        hex(&h.finalize())
    }
}

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Read a `.whl` or `.tar.gz` and its metadata.
pub fn read_dist(path: &Path) -> Result<DistFile, String> {
    let file_name = path
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| format!("unreadable file name: {}", path.display()))?
        .to_string();
    let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    if let Some(stem) = file_name.strip_suffix(".whl") {
        let parts: Vec<&str> = stem.split('-').collect();
        if parts.len() != 5 && parts.len() != 6 {
            return Err(format!("`{file_name}` is not a PEP 427 wheel name (name-version[-build]-py-abi-platform.whl)"));
        }
        let pytag = parts[parts.len() - 3].to_string();
        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes))
            .map_err(|e| format!("{file_name} is not a zip: {e}"))?;
        let md_names: Vec<String> = zip
            .file_names()
            .flatten()
            .filter(|n| n.ends_with(".dist-info/METADATA") && n.matches('/').count() == 1)
            .map(|n| n.to_string())
            .collect();
        let [md_name] = md_names.as_slice() else {
            return Err(format!("{file_name}: expected exactly one *.dist-info/METADATA, found {md_names:?}"));
        };
        let mut text = String::new();
        zip.by_name(md_name)
            .map_err(|e| format!("{file_name}: {md_name}: {e}"))?
            .read_to_string(&mut text)
            .map_err(|e| format!("{file_name}: {md_name} is not UTF-8: {e}"))?;
        return Ok(DistFile {
            path: path.to_path_buf(),
            file_name: file_name.clone(),
            filetype: "bdist_wheel",
            pyversion: pytag,
            name_in_filename: parts[0].to_string(),
            version_in_filename: parts[1].to_string(),
            metadata: Metadata::parse(&text),
            bytes,
        });
    }
    if let Some(stem) = file_name.strip_suffix(".tar.gz") {
        // PEP 625: `{name}-{version}.tar.gz`, name with `_` for separators.
        let (name, version) = stem
            .rsplit_once('-')
            .ok_or_else(|| format!("`{file_name}` is not a PEP 625 sdist name (name-version.tar.gz)"))?;
        let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes));
        let mut ar = tar::Archive::new(gz);
        let want = format!("{stem}/PKG-INFO");
        let mut text = None;
        for entry in ar.entries().map_err(|e| format!("{file_name}: {e}"))? {
            let mut entry = entry.map_err(|e| format!("{file_name}: {e}"))?;
            let p = entry.path().map_err(|e| format!("{file_name}: {e}"))?.to_string_lossy().into_owned();
            if p == want {
                let mut s = String::new();
                entry.read_to_string(&mut s).map_err(|e| format!("{file_name}: PKG-INFO: {e}"))?;
                text = Some(s);
                break;
            }
        }
        let text = text.ok_or_else(|| format!("{file_name} has no top-level {want}"))?;
        return Ok(DistFile {
            path: path.to_path_buf(),
            file_name: file_name.clone(),
            filetype: "sdist",
            pyversion: "source".to_string(),
            name_in_filename: name.to_string(),
            version_in_filename: version.to_string(),
            metadata: Metadata::parse(&text),
            bytes,
        });
    }
    Err(format!("`{file_name}` is neither a wheel (.whl) nor an sdist (.tar.gz)"))
}

// ─────────────────────────────────────────────────────────────────────────────
// The checks — what PyPI (and `twine check`) would refuse, said before sending
// ─────────────────────────────────────────────────────────────────────────────

/// PEP 508 name: letters/digits, `.`/`_`/`-` inside only.
fn valid_name(name: &str) -> bool {
    let b = name.as_bytes();
    !b.is_empty()
        && b[0].is_ascii_alphanumeric()
        && b[b.len() - 1].is_ascii_alphanumeric()
        && b.iter().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'))
}

/// A PUBLIC PEP 440 version in canonical form (`N(.N)*` + optional `aN`/`bN`/`rcN`,
/// `.postN`, `.devN`). Local versions (`+abc`) are refused: PyPI does not accept them.
fn valid_public_version(v: &str) -> bool {
    if v.contains('+') || v.is_empty() {
        return false;
    }
    let mut rest = v;
    // release segment
    let mut n_parts = 0;
    loop {
        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
        if digits == 0 {
            return false;
        }
        n_parts += 1;
        rest = &rest[digits..];
        if let Some(r) = rest.strip_prefix('.') && r.bytes().next().is_some_and(|c| c.is_ascii_digit()) {
            rest = r;
            continue;
        }
        break;
    }
    if n_parts == 0 {
        return false;
    }
    for prefix in ["a", "b", "rc"] {
        if let Some(r) = rest.strip_prefix(prefix) {
            let d = r.bytes().take_while(u8::is_ascii_digit).count();
            if d == 0 {
                return false;
            }
            rest = &r[d..];
            break;
        }
    }
    for prefix in [".post", ".dev"] {
        if let Some(r) = rest.strip_prefix(prefix) {
            let d = r.bytes().take_while(u8::is_ascii_digit).count();
            if d == 0 {
                return false;
            }
            rest = &r[d..];
        }
    }
    rest.is_empty()
}

/// What PyPI would refuse (or show broken), one line each. Empty = sendable.
#[must_use]
pub fn check_metadata(f: &DistFile) -> Vec<String> {
    let md = &f.metadata;
    let mut bad = Vec::new();

    // Metadata-Version: warehouse accepts 1.0 … 2.4.
    match md.get("Metadata-Version") {
        Some(v) if ["1.0", "1.1", "1.2", "2.0", "2.1", "2.2", "2.3", "2.4"].contains(&v) => {}
        Some(v) => bad.push(format!("Metadata-Version `{v}` is not one PyPI accepts (1.0–2.4)")),
        None => bad.push("no Metadata-Version".to_string()),
    }

    // Name and Version, and that they agree with the filename.
    let name = md.get("Name").unwrap_or("");
    if !valid_name(name) {
        bad.push(format!("Name `{name}` is not a valid project name"));
    } else if normalize_name(name) != normalize_name(&f.name_in_filename) {
        bad.push(format!(
            "the file is named for `{}` but its metadata says `{name}`",
            f.name_in_filename
        ));
    }
    let version = md.get("Version").unwrap_or("");
    if !valid_public_version(version) {
        bad.push(format!("Version `{version}` is not a public PEP 440 version"));
    } else if version != f.version_in_filename {
        bad.push(format!(
            "the file says version {} but its metadata says {version}",
            f.version_in_filename
        ));
    }

    // Summary: present, one line, ≤ 512 chars (warehouse's limit).
    match md.get("Summary") {
        None | Some("") => bad.push("no Summary — PyPI shows a blank one-line description".to_string()),
        Some(s) if s.contains('\n') => bad.push("Summary spans more than one line".to_string()),
        Some(s) if s.chars().count() > 512 => bad.push(format!("Summary is {} chars, PyPI's limit is 512", s.chars().count())),
        _ => {}
    }

    // The readme, rendered as DECLARED.
    match (md.description(), md.get("Description-Content-Type")) {
        (None, _) => bad.push("no long description (the readme) — the PyPI page would be empty".to_string()),
        (Some(_), None) => bad.push(
            "no Description-Content-Type — PyPI would render the readme as reStructuredText and a \
             markdown readme shows as raw text"
                .to_string(),
        ),
        (Some(d), Some(ct)) => bad.extend(check_readme(d, ct)),
    }

    // Requires-Python must parse as specifiers (`>=3.9`, `>=3.9,<4`).
    if let Some(rp) = md.get("Requires-Python") {
        let ok = rp.split(',').all(|s| {
            let s = s.trim();
            ["===", "~=", "==", "!=", "<=", ">=", "<", ">"]
                .iter()
                .find_map(|op| s.strip_prefix(op))
                .is_some_and(|v| {
                    let v = v.trim().trim_end_matches(".*");
                    !v.is_empty() && v.split('.').all(|p| !p.is_empty() && p.bytes().all(|c| c.is_ascii_digit()))
                })
        });
        if !ok {
            bad.push(format!("Requires-Python `{rp}` is not a version specifier"));
        }
    }

    // Project-URL: `Label, URL`, label ≤ 32 chars, an http(s) URL.
    for pu in md.all("Project-URL") {
        match pu.split_once(',') {
            Some((label, url)) if label.trim().chars().count() <= 32
                && (url.trim().starts_with("https://") || url.trim().starts_with("http://")) => {}
            _ => bad.push(format!("Project-URL `{pu}` is not `Label (≤32), http(s)://…`")),
        }
    }

    // Every `License-File` names a text the file really carries (PEP 639: under the
    // wheel's `.dist-info/licenses/`, at the sdist's top level) — a license that
    // points at nothing is a license the copy does not come with.
    for lf in md.all("License-File") {
        let want = match f.filetype {
            "bdist_wheel" => format!("{}-{}.dist-info/licenses/{lf}", f.name_in_filename, f.version_in_filename),
            _ => format!("{}-{}/{lf}", f.name_in_filename, f.version_in_filename),
        };
        if !member_names(f).contains(&want) {
            bad.push(format!("License-File `{lf}` is declared but `{want}` is not in the file"));
        }
    }

    // Metadata 2.4 says license as an SPDX expression and never ALSO as a classifier.
    if md.get("License-Expression").is_some()
        && md.all("Classifier").iter().any(|c| c.starts_with("License ::"))
    {
        bad.push("License-Expression AND a `License ::` classifier — PEP 639 forbids both".to_string());
    }
    bad
}

/// The member paths of a wheel (zip) or an sdist (tar.gz); empty when unreadable
/// (the reader already refused such a file).
fn member_names(f: &DistFile) -> Vec<String> {
    if f.filetype == "bdist_wheel" {
        zip::ZipArchive::new(std::io::Cursor::new(&f.bytes))
            .map(|z| z.file_names().flatten().map(|n| n.to_string()).collect())
            .unwrap_or_default()
    } else {
        let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&f.bytes));
        let mut ar = tar::Archive::new(gz);
        let Ok(entries) = ar.entries() else { return Vec::new() };
        entries
            .filter_map(|e| e.ok().and_then(|e| e.path().ok().map(|p| p.to_string_lossy().into_owned())))
            .collect()
    }
}

/// The readme as its declared content type would render on PyPI.
fn check_readme(text: &str, content_type: &str) -> Vec<String> {
    let mut bad = Vec::new();
    let ct = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
    match ct.as_str() {
        "text/markdown" => {
            use pulldown_cmark::{Event, Options, Parser, Tag};
            let parser = Parser::new_ext(text, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
            let mut events = Vec::new();
            for ev in parser {
                match &ev {
                    Event::Start(Tag::Link { dest_url, .. }) | Event::Start(Tag::Image { dest_url, .. }) => {
                        let u = dest_url.as_ref();
                        let absolute = u.starts_with("https://") || u.starts_with("http://") || u.starts_with("mailto:") || u.starts_with('#');
                        if !absolute {
                            bad.push(format!(
                                "the readme links `{u}` RELATIVELY — on pypi.org that resolves against \
                                 the PyPI page and 404s; use an absolute URL"
                            ));
                        }
                    }
                    _ => {}
                }
                events.push(ev);
            }
            let mut html = String::new();
            pulldown_cmark::html::push_html(&mut html, events.into_iter());
            if html.trim().is_empty() {
                bad.push("the markdown readme renders to nothing".to_string());
            }
        }
        "text/plain" => {
            if text.trim().is_empty() {
                bad.push("the plain-text readme is empty".to_string());
            }
        }
        "text/x-rst" => bad.push(
            "the readme is reStructuredText, which this checker cannot render — PyPI refuses an \
             rst readme that fails to render; ship markdown (as both facett packages do)"
                .to_string(),
        ),
        other => bad.push(format!(
            "Description-Content-Type `{other}` is not one PyPI renders (text/markdown, text/x-rst, text/plain)"
        )),
    }
    bad
}

/// The offline half of the check, for a path — what `publish-pip` runs on every wheel
/// it builds, so a wheel PyPI would refuse never leaves the build.
pub fn check_wheel_offline(path: &Path) -> Result<DistFile, String> {
    let f = read_dist(path)?;
    let bad = check_metadata(&f);
    if !bad.is_empty() {
        return Err(format!("{}:\n  - {}", f.file_name, bad.join("\n  - ")));
    }
    name_gate(&f)?;
    Ok(f)
}

// ─────────────────────────────────────────────────────────────────────────────
// The name gate — no personal name, personal mail domain or home path leaves
// ─────────────────────────────────────────────────────────────────────────────

/// Words no published file may carry ANYWHERE — in a member's name or in its
/// (decompressed) bytes, text or wasm: personal names, the personal mail domain,
/// an absolute home path. Lowercase; matched ASCII-case-insensitively over raw bytes,
/// so a name compiled into a wasm string table is found as surely as one in a `.py`.
/// Spelled in pieces so this source passes the same grep the gate runs (a repo-wide
/// scrub should not have to except the gate itself).
pub const NAME_GATE_WORDS: &[&str] =
    &[concat!("rick", "ard"), concat!("lun", "din"), concat!("hen", "rik"), concat!("x14", ".se"), concat!("/ho", "me/")];

/// Words matched only as a TOKEN — no ASCII letter or digit directly before or
/// after. The personal mail domain's three letters are a substring of every
/// `submit`: the 0.2.0 wheels carry 200+ of them (`wgpu::Queue::submit`,
/// `on_submitted_work_done`, saga's `SubmitCountRequest`) and not one real hit, so as
/// a substring the gate would refuse every wheel and be switched off by the first
/// person it annoyed. As a token it still catches the domain, `@` + it, upper case,
/// and the word quoted.
pub const NAME_GATE_TOKENS: &[&str] = &[concat!("u", "bm")];

/// One hit: which file, which member (or the member's NAME), where, what.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NameHit {
    /// The distribution file.
    pub file: String,
    /// The archive member (`<name> (member name)` when the hit is in the path itself).
    pub member: String,
    /// Byte offset in the DECOMPRESSED member (or in its name).
    pub offset: usize,
    /// The gate word that matched.
    pub word: &'static str,
    /// The bytes around it, non-printables as `.`.
    pub context: String,
}

impl fmt::Display for NameHit {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {} @ byte {}: `{}` in `{}`", self.file, self.member, self.offset, self.word, self.context)
    }
}

/// The owner's ownership credit (owner decision 2026-10-03): the ONE place a personal
/// name may be published — on an ownership line (`Copyright` / `SPDX-FileCopyrightText`
/// / `©`) that carries exactly this text. Spelled in pieces for the same reason as
/// [`NAME_GATE_WORDS`].
pub const OWNERSHIP_CREDIT: &str =
    concat!("Ignalina ApS (", "Rick", "ard Ernst Bj\u{f6}rn ", "Lun", "din)");

/// Line markers that make a line an ownership line.
pub const OWNERSHIP_MARKERS: &[&str] = &["Copyright", "SPDX-FileCopyrightText", "\u{a9}"];

/// `true` when the hit at `offset..offset + len` lies inside [`OWNERSHIP_CREDIT`] on a
/// line that also carries an [`OWNERSHIP_MARKERS`] word — the exemption. Anything else
/// on that line, or the same name on any other line, still counts as a hit.
#[must_use]
pub fn is_ownership_credit(bytes: &[u8], offset: usize, len: usize) -> bool {
    let start = bytes[..offset].iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1);
    let end = bytes[offset..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |i| offset + i);
    let line = &bytes[start..end];
    let has = |needle: &[u8]| line.windows(needle.len()).position(|w| w == needle);
    if !OWNERSHIP_MARKERS.iter().any(|m| has(m.as_bytes()).is_some()) {
        return false;
    }
    let credit = OWNERSHIP_CREDIT.as_bytes();
    let mut from = 0;
    while let Some(p) = line[from..].windows(credit.len()).position(|w| w == credit) {
        let c0 = start + from + p;
        if offset >= c0 && offset + len <= c0 + credit.len() {
            return true;
        }
        from += p + 1;
    }
    false
}

/// Every `(offset, word)` in `bytes`, both gate lists, case-insensitively — minus the
/// owner's credit on an ownership line ([`is_ownership_credit`]).
#[must_use]
pub fn scan_for_names(bytes: &[u8]) -> Vec<(usize, &'static str)> {
    let mut hits = scan_for_names_raw(bytes);
    hits.retain(|&(offset, w)| !is_ownership_credit(bytes, offset, w.len()));
    hits
}

fn scan_for_names_raw(bytes: &[u8]) -> Vec<(usize, &'static str)> {
    let alnum = |i: Option<usize>| i.and_then(|i| bytes.get(i)).is_some_and(u8::is_ascii_alphanumeric);
    let mut hits = Vec::new();
    for (words, token) in [(NAME_GATE_WORDS, false), (NAME_GATE_TOKENS, true)] {
        for &w in words {
            let w_b = w.as_bytes();
            let first = w_b[0];
            let mut i = 0;
            while i + w_b.len() <= bytes.len() {
                if bytes[i].to_ascii_lowercase() == first
                    && bytes[i..i + w_b.len()].iter().zip(w_b).all(|(a, b)| a.to_ascii_lowercase() == *b)
                    && !(token && (alnum(i.checked_sub(1)) || alnum(Some(i + w_b.len()))))
                {
                    hits.push((i, w));
                    i += w_b.len();
                    continue;
                }
                i += 1;
            }
        }
    }
    hits.sort_unstable();
    hits
}

fn context_at(bytes: &[u8], offset: usize, len: usize) -> String {
    let lo = offset.saturating_sub(24);
    let hi = (offset + len + 24).min(bytes.len());
    bytes[lo..hi].iter().map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }).collect()
}

fn scan_member(file: &str, member: &str, bytes: &[u8], out: &mut Vec<NameHit>) {
    for (offset, word) in scan_for_names(member.as_bytes()) {
        out.push(NameHit {
            file: file.to_string(),
            member: format!("{member} (member name)"),
            offset,
            word,
            context: member.to_string(),
        });
    }
    for (offset, word) in scan_for_names(bytes) {
        out.push(NameHit {
            file: file.to_string(),
            member: member.to_string(),
            offset,
            word,
            context: context_at(bytes, offset, word.len()),
        });
    }
    // A gzip member inside the archive is read through, one level: a name does not
    // become publishable by being compressed twice.
    if member.ends_with(".gz") {
        let mut inner = Vec::new();
        if flate2::read::GzDecoder::new(bytes).read_to_end(&mut inner).is_ok() {
            scan_member(file, &format!("{member} (gunzipped)"), &inner, out);
        }
    }
}

/// Every name hit in one distribution file: its file name, then every zip (wheel) or
/// tar (sdist) member — its path and its DECOMPRESSED bytes.
pub fn name_hits(f: &DistFile) -> Result<Vec<NameHit>, String> {
    let mut out = Vec::new();
    let file = f.file_name.as_str();
    for (offset, word) in scan_for_names(file.as_bytes()) {
        out.push(NameHit { file: file.into(), member: "(the file name)".into(), offset, word, context: file.into() });
    }
    if f.filetype == "bdist_wheel" {
        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&f.bytes)).map_err(|e| format!("{file} is not a zip: {e}"))?;
        for i in 0..zip.len() {
            let mut m = zip.by_index(i).map_err(|e| format!("{file}: member {i}: {e}"))?;
            let name = m
                .name()
                .map_err(|e| format!("{file}: member {i}: {e}"))?
                .to_string();
            let mut bytes = Vec::with_capacity(m.size() as usize);
            m.read_to_end(&mut bytes).map_err(|e| format!("{file}: {name}: {e}"))?;
            scan_member(file, &name, &bytes, &mut out);
        }
    } else {
        let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&f.bytes));
        let mut ar = tar::Archive::new(gz);
        for entry in ar.entries().map_err(|e| format!("{file}: {e}"))? {
            let mut entry = entry.map_err(|e| format!("{file}: {e}"))?;
            let name = entry.path().map_err(|e| format!("{file}: {e}"))?.to_string_lossy().into_owned();
            // A link target is a path too.
            let link = entry.link_name().ok().flatten().map(|l| l.to_string_lossy().into_owned());
            let mut bytes = Vec::new();
            entry.read_to_end(&mut bytes).map_err(|e| format!("{file}: {name}: {e}"))?;
            if let Some(l) = link {
                bytes.extend_from_slice(l.as_bytes());
            }
            scan_member(file, &name, &bytes, &mut out);
        }
    }
    Ok(out)
}

/// **The hard gate.** `Err` naming every hit (member + byte offset + context) when
/// the file carries a personal name; nothing with a hit is ever uploaded, dry run or
/// not, and `publish-pip` refuses to call such a wheel built.
pub fn name_gate(f: &DistFile) -> Result<(), String> {
    let hits = name_hits(f)?;
    if hits.is_empty() {
        return Ok(());
    }
    const SHOWN: usize = 40;
    let mut lines: Vec<String> = hits.iter().take(SHOWN).map(ToString::to_string).collect();
    if hits.len() > SHOWN {
        lines.push(format!("… and {} more", hits.len() - SHOWN));
    }
    Err(format!(
        "{} carries {} personal-name hit(s) ({} / token {}) — scrub the source and rebuild:\n  - {}",
        f.file_name,
        hits.len(),
        NAME_GATE_WORDS.join("|"),
        NAME_GATE_TOKENS.join("|"),
        lines.join("\n  - ")
    ))
}

/// Classifiers PyPI does not know — the index refuses the upload over any one of them.
#[must_use]
pub fn unknown_classifiers<'a>(f: &'a DistFile, known: &[String]) -> Vec<&'a str> {
    f.metadata.all("Classifier").into_iter().filter(|c| !known.iter().any(|k| k == c)).collect()
}

// ─────────────────────────────────────────────────────────────────────────────
// The wire
// ─────────────────────────────────────────────────────────────────────────────

/// The form fields (everything but the file), in the names warehouse's upload form
/// reads. Repeatable metadata fields become repeated form fields.
#[must_use]
pub fn form_fields(f: &DistFile) -> Vec<(String, String)> {
    let md = &f.metadata;
    let mut out: Vec<(String, String)> = vec![
        (":action".into(), "file_upload".into()),
        ("protocol_version".into(), "1".into()),
        ("filetype".into(), f.filetype.into()),
        ("pyversion".into(), f.pyversion.clone()),
        ("sha256_digest".into(), f.sha256_hex()),
        ("blake2_256_digest".into(), f.blake2_256_hex()),
    ];
    let single = [
        ("Metadata-Version", "metadata_version"),
        ("Name", "name"),
        ("Version", "version"),
        ("Summary", "summary"),
        ("Home-page", "home_page"),
        ("Download-URL", "download_url"),
        ("Author", "author"),
        ("Author-email", "author_email"),
        ("Maintainer", "maintainer"),
        ("Maintainer-email", "maintainer_email"),
        ("License", "license"),
        ("License-Expression", "license_expression"),
        ("Keywords", "keywords"),
        ("Requires-Python", "requires_python"),
        ("Description-Content-Type", "description_content_type"),
    ];
    for (k, form) in single {
        if let Some(v) = md.get(k) {
            out.push((form.into(), v.into()));
        }
    }
    if let Some(d) = md.description() {
        out.push(("description".into(), d.into()));
    }
    let multi = [
        ("Classifier", "classifiers"),
        ("Requires-Dist", "requires_dist"),
        ("Provides-Extra", "provides_extra"),
        ("Project-URL", "project_urls"),
        ("Platform", "platform"),
        ("Supported-Platform", "supported_platform"),
        ("Requires-External", "requires_external"),
        ("Provides-Dist", "provides_dist"),
        ("Obsoletes-Dist", "obsoletes_dist"),
        ("License-File", "license_file"),
        ("Dynamic", "dynamic"),
    ];
    for (k, form) in multi {
        for v in md.all(k) {
            out.push((form.into(), v.into()));
        }
    }
    out
}

/// A `multipart/form-data` body: the fields, then the file as `content`.
#[must_use]
pub fn multipart_body(fields: &[(String, String)], file_name: &str, bytes: &[u8], boundary: &str) -> Vec<u8> {
    let mut b: Vec<u8> = Vec::with_capacity(bytes.len() + 4096);
    for (k, v) in fields {
        b.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n").as_bytes());
        b.extend_from_slice(v.as_bytes());
        b.extend_from_slice(b"\r\n");
    }
    b.extend_from_slice(
        format!(
            "--{boundary}\r\nContent-Disposition: form-data; name=\"content\"; filename=\"{file_name}\"\r\n\
             Content-Type: application/octet-stream\r\n\r\n"
        )
        .as_bytes(),
    );
    b.extend_from_slice(bytes);
    b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
    b
}

/// A boundary that does not occur in the body: derived from the file's digest, so a
/// collision would need the file to contain its own hash.
fn boundary_for(f: &DistFile) -> String {
    format!("facett-ops-{}", &f.sha256_hex()[..32])
}

/// The one HTTP agent of this module.
///
/// (2026-10-03, finish-bump: ureq 2 -> 3) The ureq 2 settings, kept: NO redirects (a
/// redirected POST arrives as a GET; with 0, ureq 3 hands the 3xx back as a response,
/// never an error), connect 30 s, and 600 s for the whole request (ureq 2's `timeout`
/// was already the overall deadline = ureq 3's `timeout_global`). Two ureq 3 defaults
/// are switched back: no proxy is read from `HTTP(S)_PROXY` (ureq 2, built without
/// `proxy-from-env`, never did), and a 4xx/5xx is RETURNED as a response
/// (`http_status_as_error(false)`) — ureq 2 carried it in `Error::Status`, and the
/// callers read the status and the body (404 = not on the index, 400 "exists", 403).
/// ureq 3 keeps no reason phrase, so messages carry the canonical one; the server's
/// own words still arrive through the body's first line.
fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .max_redirects(0)
        .timeout_connect(Some(std::time::Duration::from_secs(30)))
        .timeout_global(Some(std::time::Duration::from_secs(600)))
        .proxy(None)
        .http_status_as_error(false)
        .user_agent(concat!("facett-ops/", env!("CARGO_PKG_VERSION"), " (publish-pypi)"))
        .build()
        .new_agent()
}

/// `404 Not Found` — the status and its canonical reason (ureq 3 drops the server's).
fn status_line(s: ureq::http::StatusCode) -> String {
    format!("{} {}", s.as_u16(), s.canonical_reason().unwrap_or(""))
}

/// Is `name==version` already on the index? (`GET <api>/pypi/<name>/<version>/json`:
/// 200 = yes, 404 = no.) PyPI never lets a file name be reused, even after a delete,
/// so this is asked BEFORE anything is sent.
pub fn version_on_index(repo: &Repository, name: &str, version: &str) -> Result<bool, String> {
    let url = format!("{}/pypi/{}/{version}/json", repo.api_base(), normalize_name(name));
    let shown = redact_url(&url);
    match agent().get(&url).call() {
        Ok(r) if r.status().as_u16() == 200 => Ok(true),
        Ok(r) if r.status().as_u16() == 404 => Ok(false),
        Ok(r) if r.status().as_u16() >= 400 => Err(format!("{shown} answered {}", status_line(r.status()))),
        Ok(r) => Err(format!("{shown} answered {} — cannot tell whether {name} {version} exists", r.status().as_u16())),
        Err(e) => Err(redact_url(&format!("{shown}: {e}"))),
    }
}

/// PyPI's own classifier list (`/pypi?:action=list_classifiers`).
pub fn known_classifiers(repo: &Repository) -> Result<Vec<String>, String> {
    let url = format!("{}/pypi?%3Aaction=list_classifiers", repo.api_base());
    let shown = redact_url(&url);
    let mut r = agent().get(&url).call().map_err(|e| redact_url(&format!("{shown}: {e}")))?;
    if r.status().as_u16() >= 400 {
        return Err(format!("{shown}: status code {}", r.status().as_u16()));
    }
    let body = r.body_mut().read_to_string().map_err(|e| redact_url(&format!("{shown}: {e}")))?;
    let list: Vec<String> = body.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
    if list.len() < 100 {
        return Err(format!("{shown} returned {} classifiers — not the list", list.len()));
    }
    Ok(list)
}

/// What an upload did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Uploaded {
    pub file_name: String,
    pub status: u16,
    pub url: String,
}

/// POST one file. The token never appears in the returned error.
pub fn upload(repo: &Repository, f: &DistFile, token: &Token) -> Result<Uploaded, String> {
    let url = repo.upload_url();
    let shown = redact_url(&url);
    let boundary = boundary_for(f);
    let body = multipart_body(&form_fields(f), &f.file_name, &f.bytes, &boundary);
    let res = agent()
        .post(&url)
        .header("Authorization", &token.basic_auth())
        .header("Content-Type", &format!("multipart/form-data; boundary={boundary}"))
        .send(&body[..]);
    match res {
        Ok(r) if r.status().is_success() => Ok(Uploaded { file_name: f.file_name.clone(), status: r.status().as_u16(), url }),
        Ok(r) if r.status().is_redirection() => Err(redact(
            &format!(
                "{shown} redirected ({} → {}) — a redirected POST arrives as a GET; use the exact upload URL",
                r.status().as_u16(),
                r.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("?")
            ),
            token,
        )),
        Ok(mut r) if r.status().as_u16() >= 400 => {
            let code = r.status().as_u16();
            let reason = r.status().canonical_reason().unwrap_or("").to_string();
            let text = r.body_mut().read_to_string().unwrap_or_default();
            let hint = match code {
                403 => " — the token was refused (wrong index? a project-scoped token for another project?)",
                400 if reason.contains("exists") || text.contains("exists") => " — this file is already on the index; bump the version",
                _ => "",
            };
            let first: String = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(300).collect();
            Err(redact(&format!("{shown} refused {}: {code} {reason}{hint} {first}", f.file_name), token))
        }
        Ok(r) => Err(redact(&format!("{shown} answered {}", status_line(r.status())), token)),
        Err(e) => Err(redact(&format!("{shown}: {e}"), token)),
    }
}

/// The dry-run report for one file: what would be sent, the digests, the size. The
/// description is summarised (first line + length), the file bytes are not printed.
#[must_use]
pub fn describe(f: &DistFile, repo: &Repository) -> String {
    let mut s = format!(
        "POST {}  ({} {}, {} bytes)\n  sha256      {}\n  blake2_256  {}\n",
        repo.upload_url(),
        f.filetype,
        f.file_name,
        f.bytes.len(),
        f.sha256_hex(),
        f.blake2_256_hex()
    );
    for (k, v) in form_fields(f) {
        if k == "sha256_digest" || k == "blake2_256_digest" {
            continue;
        }
        let shown = if k == "description" {
            format!("<{} chars, first line: {}>", v.chars().count(), v.lines().next().unwrap_or(""))
        } else {
            v
        };
        s.push_str(&format!("  {k:<24} {shown}\n"));
    }
    s.push_str(&format!("  {:<24} <{} bytes, {}>\n", "content", f.bytes.len(), f.file_name));
    s
}

/// The default set: every wheel and sdist of both releases, components FIRST (the
/// demo pins it, so the demo must never be on an index the components are not).
pub fn default_files(root: &Path) -> Result<Vec<PathBuf>, String> {
    let mut out = Vec::new();
    for r in crate::pip::Release::ALL {
        let dir = r.dist_dir(root)?;
        let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
            .map_err(|e| format!("{} — run `cargo ops publish-pip` first ({e})", dir.display()))?
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|p| {
                let n = p.to_string_lossy();
                n.ends_with(".whl") || n.ends_with(".tar.gz")
            })
            .collect();
        files.sort_by_key(|p| !p.to_string_lossy().ends_with(".whl")); // wheel before sdist
        out.extend(files);
    }
    if out.is_empty() {
        return Err("no wheels found — run `cargo ops publish-pip` first".to_string());
    }
    Ok(out)
}

/// The verb's options.
#[derive(Debug, Clone)]
pub struct PublishOptions {
    pub repository: Repository,
    pub dry_run: bool,
    pub files: Vec<PathBuf>,
}

/// Parse `[--repository testpypi|pypi] [--dry-run] [files…]`. The default index is
/// **testpypi**: the real one is named, never defaulted to.
pub fn parse_args(args: &[String]) -> Result<PublishOptions, String> {
    let mut repository = Repository::TestPypi;
    let mut dry_run = false;
    let mut files = Vec::new();
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--dry-run" => dry_run = true,
            "--repository" | "-r" => {
                repository = Repository::parse(it.next().ok_or("--repository needs `pypi` or `testpypi`")?)?;
            }
            s if s.starts_with("--repository=") => repository = Repository::parse(&s["--repository=".len()..])?,
            s if s.starts_with('-') => return Err(format!("unknown option `{s}`")),
            f => files.push(PathBuf::from(f)),
        }
    }
    Ok(PublishOptions { repository, dry_run, files })
}

/// The whole verb, minus argument defaults: check everything, then (unless dry-run)
/// upload in order, stopping at the first refusal. Returns the lines it reports. The
/// token comes from the repository's environment variable.
pub fn publish(opts: &PublishOptions) -> Result<Vec<String>, String> {
    publish_with_token(opts, None)
}

/// [`publish`] with the token handed in (`None` = from the environment) — the seam the
/// mock-server tests use, so no test has to mutate the process environment.
pub fn publish_with_token(opts: &PublishOptions, token: Option<Token>) -> Result<Vec<String>, String> {
    let repo = &opts.repository;
    let mut report = Vec::new();
    let dists: Vec<DistFile> = opts.files.iter().map(|p| read_dist(p)).collect::<Result<_, _>>()?;

    // 0. The name gate — before anything else, before the network, dry run or not.
    let named: Vec<String> = dists.iter().filter_map(|f| name_gate(f).err()).collect();
    if !named.is_empty() {
        return Err(format!("refusing to publish — personal names in {} file(s):\n{}", named.len(), named.join("\n")));
    }
    report.push(format!(
        "name gate: {} file(s), every member decompressed, 0 hits for {} / token {}",
        dists.len(),
        NAME_GATE_WORDS.join("|"),
        NAME_GATE_TOKENS.join("|")
    ));

    // 1. Offline: the metadata, the readme, the filename.
    let mut problems = Vec::new();
    for f in &dists {
        for p in check_metadata(f) {
            problems.push(format!("{}: {p}", f.file_name));
        }
    }
    // 2. Online: the classifiers PyPI knows, and whether the version is taken.
    let known = known_classifiers(repo)?;
    for f in &dists {
        for c in unknown_classifiers(f, &known) {
            problems.push(format!("{}: classifier `{c}` is not one PyPI knows — the upload would be refused", f.file_name));
        }
        let name = f.metadata.get("Name").unwrap_or("");
        let version = f.metadata.get("Version").unwrap_or("");
        if version_on_index(repo, name, version)? {
            problems.push(format!(
                "{}: {name} {version} is ALREADY on {} — PyPI never accepts a file name twice; bump the version",
                f.file_name,
                repo.label()
            ));
        }
    }
    if !problems.is_empty() {
        return Err(format!("refusing to publish — {} problem(s):\n  - {}", problems.len(), problems.join("\n  - ")));
    }
    report.push(format!(
        "checked {} file(s) for {}: metadata, readme ({}), {} classifiers known, version not yet on the index",
        dists.len(),
        repo.label(),
        "rendered as declared",
        known.len()
    ));

    if opts.dry_run {
        let token_state = match &token {
            Some(_) => "handed in (not shown)".to_string(),
            None => match Token::resolve(repo) {
                Ok((_, source)) => format!("from {source} (not shown)"),
                Err(e) => format!("NONE — a real upload would stop here: {e}"),
            },
        };
        report.push(format!("DRY RUN — nothing sent. token: {token_state}"));
        for f in &dists {
            report.push(describe(f, repo));
        }
        return Ok(report);
    }

    // 3. Upload, in order.
    let token = match token {
        Some(t) => t,
        None => Token::resolve(repo)?.0,
    };
    for f in &dists {
        let up = upload(repo, f, &token)?;
        report.push(format!("uploaded {} → {} ({})", up.file_name, repo.label(), up.status));
    }
    for f in &dists {
        report.push(format!(
            "  {}/project/{}/{}/",
            repo.api_base(),
            normalize_name(f.metadata.get("Name").unwrap_or("")),
            f.metadata.get("Version").unwrap_or("")
        ));
    }
    report.dedup();
    Ok(report)
}