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)
}