crates/nornir-release/src/source_drift.rs
//! Same-version SOURCE-DRIFT detection — the "edited without a version bump" trap.
//!
//! `cargo publish` is immutable per `<crate>@<version>`, but nothing stops a local
//! tree from EDITING a crate's source while leaving the version number untouched.
//! When that happens the registry keeps serving the OLD bytes, so a dependent's
//! `cargo publish --verify` (which strips `[patch.crates-io]` and resolves the
//! registry copy) compiles against stale code and fail-stops — one cargo-verify at
//! a time, deep in the cascade. This is exactly what bit the fat release
//! (`znippy-plugin-python`, `holger-traits`, `funnel-core`: new local API, same
//! version, broken registry copy).
//!
//! This module compares a crate's LOCAL shippable source against the copy already
//! PUBLISHED at the same version and returns a [`DriftVerdict`]:
//! * [`DriftVerdict::Drifted`] → the source changed but the version did not — a
//! hard preflight blocker demanding a version bump (`preflight::source_drift_issues`).
//! * [`DriftVerdict::Unchanged`] → byte-identical → the crate can be SKIPPED
//! (no bump, no republish). This is the lever that kills the rate-limit grind
//! on large unchanged crate sets (the facett "timeout").
//! * [`DriftVerdict::Unknown`] → couldn't determine (not published yet, network
//! or extraction failure, `cargo package --list` unavailable) → a NOTE, never
//! a false block. Correctness rule: we only ever BLOCK on a positive,
//! content-proven mismatch.
//!
//! The published tarball's `Cargo.toml` is cargo-NORMALIZED (path-deps stripped,
//! inheritance resolved, keys reordered), so it is excluded; the RAW manifest is
//! compared via the tarball's verbatim `Cargo.toml.orig`, canonicalised as TOML on
//! both sides so mere formatting never trips a false block. `Cargo.lock` and
//! `.cargo_vcs_info.json` are synthesized and always excluded.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::Path;
use std::process::Command;
/// Verdict comparing a crate's LOCAL shippable source to the copy PUBLISHED at the
/// same version.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftVerdict {
/// Local source is (semantically) identical to the published copy at this
/// version → safe to SKIP republishing.
Unchanged,
/// Local source DIFFERS from the published copy at the SAME version — a
/// version bump is REQUIRED before publish. `differing` lists the affected
/// crate-root-relative paths (added / removed / changed), sorted and capped.
Drifted { differing: Vec<String> },
/// Couldn't determine — degrade to a note, NEVER a false block.
Unknown(String),
}
/// Max bytes we will pull for a single published `.crate` (a generous ceiling —
/// real source crates are well under this; guards against a pathological download).
const MAX_CRATE_BYTES: u64 = 64 * 1024 * 1024;
/// Cap on the number of differing paths surfaced in a [`DriftVerdict::Drifted`].
const MAX_DIFFERING_SHOWN: usize = 12;
/// An entry excluded from the drift comparison: either cargo-SYNTHESIZED
/// (`Cargo.lock`, `.cargo_vcs_info.json`) or pure VCS metadata (`.gitignore`,
/// `.gitattributes`) that can never change how a DEPENDENT compiles this crate.
/// The normalized `Cargo.toml` is excluded here too — the RAW manifest is compared
/// separately via `Cargo.toml.orig`. Excluding these avoids a false hard-block on a
/// trivial metadata edit while still catching every build-affecting source change.
fn is_excluded_entry(rel: &str) -> bool {
matches!(
rel,
"Cargo.toml" | "Cargo.lock" | ".cargo_vcs_info.json" | ".gitignore" | ".gitattributes"
)
}
/// Canonicalise a manifest's bytes for comparison: parse as TOML and re-serialise
/// deterministically, so verbatim-but-reformatted manifests (trailing newline, key
/// order) compare EQUAL. On any parse failure, fall back to the raw bytes (a
/// non-manifest or malformed file still compares exactly).
fn canonical_manifest(bytes: &[u8]) -> Vec<u8> {
match std::str::from_utf8(bytes).ok().and_then(|s| s.parse::<toml::Table>().map(toml::Value::Table).ok()) {
Some(v) => toml::to_string(&v).map(String::into_bytes).unwrap_or_else(|_| bytes.to_vec()),
None => bytes.to_vec(),
}
}
/// PURE, deterministic diff of two comparison maps keyed by crate-root-relative
/// path. Both maps are assumed already normalized (synthesized entries dropped,
/// `Cargo.toml.orig` remapped to the `Cargo.toml` key and canonicalised). This is
/// the unit-tested core — no IO.
pub fn diff_source_maps(
local: &BTreeMap<String, Vec<u8>>,
published: &BTreeMap<String, Vec<u8>>,
) -> DriftVerdict {
let mut differing: Vec<String> = Vec::new();
let keys: BTreeSet<&String> = local.keys().chain(published.keys()).collect();
for k in keys {
match (local.get(k), published.get(k)) {
(Some(a), Some(b)) if a == b => {}
(Some(_), Some(_)) => differing.push(format!("{k} (changed)")),
(Some(_), None) => differing.push(format!("{k} (added locally)")),
(None, Some(_)) => differing.push(format!("{k} (removed locally)")),
(None, None) => {}
}
}
if differing.is_empty() {
DriftVerdict::Unchanged
} else {
DriftVerdict::Drifted { differing }
}
}
/// Render a capped, human-readable list of the differing paths.
pub fn summarize_differing(differing: &[String]) -> String {
if differing.len() <= MAX_DIFFERING_SHOWN {
differing.join(", ")
} else {
format!(
"{}, (+{} more)",
differing[..MAX_DIFFERING_SHOWN].join(", "),
differing.len() - MAX_DIFFERING_SHOWN
)
}
}
/// Decide whether a crate may SKIP re-publishing during a `--bump` cascade: ONLY
/// when it already has a published version AND its source is byte-identical to it
/// ([`DriftVerdict::Unchanged`]). A first publish (no `published_max`), a drift, or
/// an inconclusive verdict all ⇒ do NOT skip (bump + publish) — the cascade never
/// silently skips a crate it could not PROVE is unchanged. Pure and testable.
pub fn can_skip_republish(published_max: Option<&str>, verdict: &DriftVerdict) -> bool {
published_max.is_some() && matches!(verdict, DriftVerdict::Unchanged)
}
/// The published registry, as a source of truth for a crate's shipped bytes —
/// injectable so [`assess`] is testable without network.
pub trait PublishedSourceOracle {
/// Fetch the NORMALIZED published comparison map for `<krate>@<version>`
/// (synthesized entries dropped, `Cargo.toml.orig` → canonical `Cargo.toml`),
/// or an `Err(reason)` on any failure (⇒ [`DriftVerdict::Unknown`]).
fn published_sources(&self, krate: &str, version: &str) -> Result<BTreeMap<String, Vec<u8>>, String>;
}
/// The real crates.io CDN oracle.
pub struct CratesIoSources;
impl PublishedSourceOracle for CratesIoSources {
fn published_sources(&self, krate: &str, version: &str) -> Result<BTreeMap<String, Vec<u8>>, String> {
let bytes = download_crate(krate, version)?;
extract_published_map(&bytes)
}
}
/// GET the published `.crate` tarball from the crates.io CDN (static host, no
/// redirect) via the vendored `ureq` client with the baked-in UA and a timeout.
fn download_crate(krate: &str, version: &str) -> Result<Vec<u8>, String> {
let url = format!("https://static.crates.io/crates/{krate}/{krate}-{version}.crate");
// ureq 3 (2026-10-02): no env proxy and 5 redirects, as ureq 2 did.
let resp = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.proxy(None)
.max_redirects(5)
.build()
.new_agent()
.get(&url)
.header("User-Agent", "nornir-release (+https://git.gunnar.rs/nordisk/edda)")
.call()
.map_err(|e| format!("download {url}: {e}"))?;
let mut buf = Vec::new();
resp.into_body()
.into_reader()
.take(MAX_CRATE_BYTES)
.read_to_end(&mut buf)
.map_err(|e| format!("read {url}: {e}"))?;
if buf.is_empty() {
return Err(format!("empty tarball at {url}"));
}
Ok(buf)
}
/// Gunzip + untar a `.crate` blob into the NORMALIZED published comparison map.
/// Entry paths carry a leading `<krate>-<version>/` prefix which is stripped.
pub fn extract_published_map(crate_bytes: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, String> {
let gz = flate2::read::GzDecoder::new(crate_bytes);
let mut ar = tar::Archive::new(gz);
let mut map: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for entry in ar.entries().map_err(|e| format!("tar entries: {e}"))? {
let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
if !entry.header().entry_type().is_file() {
continue;
}
let path = entry.path().map_err(|e| format!("tar path: {e}"))?.to_path_buf();
// Strip the top-level `<krate>-<version>/` directory component.
let rel: String = {
let mut comps = path.components();
comps.next(); // drop the prefix dir
comps.as_path().to_string_lossy().replace('\\', "/")
};
if rel.is_empty() {
continue;
}
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| format!("read {rel}: {e}"))?;
// Normalize: the verbatim `Cargo.toml.orig` becomes the canonical
// `Cargo.toml` comparison key; the cargo-normalized `Cargo.toml` and other
// synthesized entries are dropped.
if rel == "Cargo.toml.orig" {
map.insert("Cargo.toml".to_string(), canonical_manifest(&bytes));
} else if !is_excluded_entry(&rel) {
map.insert(rel, bytes);
}
}
Ok(map)
}
/// Where an entry named by `cargo package --list` actually lives on disk when it
/// is NOT at `<crate_dir>/<listed name>`.
///
/// A `readme`/`license-file` pointing OUTSIDE the package (`readme = "../README.md"`,
/// the shape every znippy and skade member uses to share one repo-root README) is
/// COPIED by cargo into the package root under its bare file name. `cargo package
/// --list` therefore prints `README.md`, but `<crate_dir>/README.md` does not exist —
/// and the naive join made [`local_shipped_sources`] fail with
/// `read <crate>/README.md: No such file or directory`, which [`assess`] turns into
/// [`DriftVerdict::Unknown`]. Six of znippy's twelve crates read Unknown that way, so
/// crates byte-identical to their published copy never reached the SKIP set and an
/// `--execute` would have bumped and re-uploaded them for nothing.
///
/// Returns `listed name → real on-disk path`, and ONLY for a manifest key whose
/// declared path resolves to an existing file. An in-package `readme = "docs/R.md"`
/// is listed under its own relative path and already resolves, so it never lands
/// here. A workspace-inherited (`readme.workspace = true`) value is not resolvable
/// from this manifest alone and is deliberately left out — the old Unknown stands,
/// which is a NOTE, never a false block.
fn out_of_package_asset_paths(crate_dir: &Path) -> BTreeMap<String, std::path::PathBuf> {
let mut map = BTreeMap::new();
let Ok(text) = std::fs::read_to_string(crate_dir.join("Cargo.toml")) else {
return map;
};
let Ok(doc) = text.parse::<toml::Table>().map(toml::Value::Table) else {
return map;
};
let Some(pkg) = doc.get("package") else {
return map;
};
for key in ["readme", "license-file"] {
// `readme = false` / `readme.workspace = true` are not string paths.
let Some(declared) = pkg.get(key).and_then(|v| v.as_str()) else {
continue;
};
// Only a path that LEAVES the package is copied in under its bare name; an
// in-package `docs/README.md` keeps its relative path in the listing and
// already resolves, so it must never enter this map.
let p = Path::new(declared);
let escapes = p.is_absolute()
|| p.components().next() == Some(std::path::Component::ParentDir);
if !escapes {
continue;
}
let on_disk = crate_dir.join(declared);
let Some(listed) = p.file_name() else {
continue;
};
let listed = listed.to_string_lossy().into_owned();
if !crate_dir.join(&listed).exists() && on_disk.is_file() {
map.insert(listed, on_disk);
}
}
map
}
/// Collect the LOCAL shippable source into the NORMALIZED comparison map: ask
/// `cargo package --list` for the exact file set cargo would ship, then read each
/// from disk (`Cargo.toml` canonicalised under its own key; the synthesized
/// `Cargo.toml.orig`/`Cargo.lock`/`.cargo_vcs_info.json` skipped — they aren't
/// authored source on disk).
///
/// An out-of-package `readme`/`license-file` is resolved through
/// [`out_of_package_asset_paths`] — cargo ships it under its bare file name, which
/// does not exist at that name inside the crate dir.
pub fn local_shipped_sources(crate_dir: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
let manifest = crate_dir.join("Cargo.toml");
let cargo = cargo_bin();
let out = Command::new(&cargo)
.args(["package", "--list", "--allow-dirty", "--manifest-path"])
.arg(&manifest)
.output()
.map_err(|e| format!("spawn `{} package --list`: {e}", cargo.to_string_lossy()))?;
if !out.status.success() {
return Err(format!(
"`cargo package --list` failed: {}",
String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").trim()
));
}
let listing = String::from_utf8_lossy(&out.stdout);
let relocated = out_of_package_asset_paths(crate_dir);
let mut map: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for line in listing.lines().map(str::trim).filter(|l| !l.is_empty()) {
let rel = line.replace('\\', "/");
if rel == "Cargo.toml" {
// The raw manifest, compared (canonicalised) against the tarball's
// `Cargo.toml.orig` — NOT excluded, unlike the tarball's normalized copy.
let disk = crate_dir.join(&rel);
let bytes = std::fs::read(&disk).map_err(|e| format!("read {}: {e}", disk.display()))?;
map.insert("Cargo.toml".to_string(), canonical_manifest(&bytes));
continue;
}
// `Cargo.toml.orig` is synthesized at package time (absent on disk); the
// rest of the denylist matches the published side.
if rel == "Cargo.toml.orig" || is_excluded_entry(&rel) {
continue;
}
let disk = match relocated.get(&rel) {
Some(p) => p.clone(),
None => crate_dir.join(&rel),
};
let bytes = std::fs::read(&disk).map_err(|e| format!("read {}: {e}", disk.display()))?;
map.insert(rel, bytes);
}
if map.is_empty() {
return Err("`cargo package --list` produced no files".to_string());
}
Ok(map)
}
/// Assess ONE crate: fetch the published sources at `version`, collect the local
/// shipped sources from `crate_dir`, and diff. Any IO failure ⇒
/// [`DriftVerdict::Unknown`] (never a false block).
pub fn assess(
oracle: &dyn PublishedSourceOracle,
crate_dir: &Path,
krate: &str,
version: &str,
) -> DriftVerdict {
let published = match oracle.published_sources(krate, version) {
Ok(m) => m,
Err(e) => return DriftVerdict::Unknown(e),
};
let local = match local_shipped_sources(crate_dir) {
Ok(m) => m,
Err(e) => return DriftVerdict::Unknown(e),
};
diff_source_maps(&local, &published)
}
/// How a crate was chosen for (or dropped from) a smart release selection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectReason {
/// Its own source drifted from the published copy (or it is a first publish /
/// its drift verdict was inconclusive) — must (re)publish.
Changed,
/// Unchanged itself, but a crate it depends on is (re)publishing, so its
/// resolved dep versions move — the reverse-dep cascade pulls it in.
CascadedFrom(String),
/// Forced in by `--all` / `--include-unchanged` (skip-logic disabled).
Forced,
/// Named explicitly by `--only`.
OnlyNamed,
/// Proven byte-identical to its published version and no changed dependency —
/// SKIPPED (the smart default).
SkippedUnchanged,
/// Dropped by `--exclude`.
Excluded,
}
/// The outcome of [`select_republish`]: the crates to publish (topo order
/// preserved) and the ones skipped, each with a human reason.
#[derive(Debug, Clone, Default)]
pub struct ReleaseSelection {
/// Crates to (re)publish, in the input (topo) order.
pub publish: Vec<String>,
/// `(crate, reason)` for each crate NOT published.
pub skipped: Vec<(String, SelectReason)>,
}
impl ReleaseSelection {
/// True when a crate is in the publish set.
pub fn will_publish(&self, krate: &str) -> bool {
self.publish.iter().any(|c| c == krate)
}
}
/// **Smart release selection** — the pure decision core behind "don't re-release
/// unchanged crates" (design §4). Given the full candidate set in topo order
/// (deps-first), the set of crates PROVEN unchanged (skippable — from
/// [`can_skip_republish`]), and the crate→its-dependencies edges (within the
/// candidate set), decide which crates actually (re)publish:
///
/// * **default (smart):** a crate publishes iff its own source CHANGED (not in
/// `unchanged`) OR any crate it depends on is publishing — the changed set plus
/// its reverse-dependency cascade. Everything else is skipped as unchanged.
/// * `--only` (`only` non-empty): restrict the candidate base to exactly those
/// crates (still minus `exclude`); skip-logic does not apply (an explicit ask).
/// * `--exclude`: drop these crates from the base entirely.
/// * `force_all` (`--all` / `--include-unchanged`): disable the skip — every base
/// crate publishes.
///
/// Deterministic + IO-free (the network drift assessment happens upstream and is
/// distilled into `unchanged`), so it is exhaustively unit-testable. The cascade
/// is a fixpoint, robust to any input ordering.
pub fn select_republish(
candidates_topo: &[String],
unchanged: &BTreeSet<String>,
deps: &BTreeMap<String, BTreeSet<String>>,
only: &[String],
exclude: &[String],
force_all: bool,
) -> ReleaseSelection {
let exclude_set: BTreeSet<&str> = exclude.iter().map(String::as_str).collect();
let only_set: BTreeSet<&str> = only.iter().map(String::as_str).collect();
// Base candidate set (topo order preserved), after --only / --exclude.
let base: Vec<String> = candidates_topo
.iter()
.filter(|c| only_set.is_empty() || only_set.contains(c.as_str()))
.filter(|c| !exclude_set.contains(c.as_str()))
.cloned()
.collect();
let base_set: BTreeSet<&str> = base.iter().map(String::as_str).collect();
let mut sel = ReleaseSelection::default();
// Excluded crates that were candidates → record the reason (visibility).
for c in candidates_topo {
if exclude_set.contains(c.as_str()) {
sel.skipped.push((c.clone(), SelectReason::Excluded));
}
}
if force_all || !only.is_empty() {
// Skip-logic disabled: every base crate publishes.
let reason = if !only.is_empty() { SelectReason::OnlyNamed } else { SelectReason::Forced };
for c in &base {
sel.publish.push(c.clone());
let _ = &reason; // reason is per-crate identical; kept for symmetry
}
// (No per-crate publish reasons stored — publish order is the contract.)
return sel;
}
// Smart default: republish = changed ∪ (reverse-dep cascade of changed).
// `cause[c]` = why c is in the republish set (Changed / CascadedFrom).
let mut cause: BTreeMap<&str, SelectReason> = BTreeMap::new();
for c in &base {
if !unchanged.contains(c.as_str()) {
cause.insert(c.as_str(), SelectReason::Changed);
}
}
// Fixpoint: pull in any base crate that depends on an already-included crate.
loop {
let mut added = false;
for c in &base {
if cause.contains_key(c.as_str()) {
continue;
}
if let Some(cdeps) = deps.get(c) {
if let Some(dep) = cdeps
.iter()
.find(|d| base_set.contains(d.as_str()) && cause.contains_key(d.as_str()))
{
cause.insert(c.as_str(), SelectReason::CascadedFrom(dep.clone()));
added = true;
}
}
}
if !added {
break;
}
}
for c in &base {
if cause.contains_key(c.as_str()) {
sel.publish.push(c.clone());
} else {
sel.skipped.push((c.clone(), SelectReason::SkippedUnchanged));
}
}
sel
}
#[cfg(test)]
mod tests {
use super::*;
fn m(pairs: &[(&str, &[u8])]) -> BTreeMap<String, Vec<u8>> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_vec())).collect()
}
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn set(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn deps(pairs: &[(&str, &[&str])]) -> BTreeMap<String, BTreeSet<String>> {
pairs.iter().map(|(k, ds)| (k.to_string(), set(ds))).collect()
}
#[test]
fn smart_default_skips_unchanged_and_cascades_reverse_deps() {
// Topo (deps-first): a → b → c ; d is standalone.
let cands = names(&["a", "b", "c", "d"]);
let dep_edges = deps(&[("b", &["a"]), ("c", &["b"]), ("d", &[])]);
// Only `a` changed; b/c/d unchanged. b depends on a → cascades; c depends
// on b → cascades; d is unrelated → stays skipped.
let unchanged = set(&["b", "c", "d"]);
let s = select_republish(&cands, &unchanged, &dep_edges, &[], &[], false);
assert_eq!(s.publish, names(&["a", "b", "c"]), "changed a + its rev-dep cascade");
assert_eq!(s.skipped.len(), 1);
assert_eq!(s.skipped[0].0, "d");
assert_eq!(s.skipped[0].1, SelectReason::SkippedUnchanged);
}
#[test]
fn all_unchanged_publishes_nothing() {
let cands = names(&["a", "b"]);
let s = select_republish(&cands, &set(&["a", "b"]), &deps(&[]), &[], &[], false);
assert!(s.publish.is_empty(), "nothing drifted ⇒ nothing to release");
assert_eq!(s.skipped.len(), 2);
}
#[test]
fn force_all_ignores_the_skip() {
let cands = names(&["a", "b"]);
let s = select_republish(&cands, &set(&["a", "b"]), &deps(&[]), &[], &[], true);
assert_eq!(s.publish, names(&["a", "b"]), "--all forces every crate");
}
#[test]
fn only_and_exclude_filter_the_base() {
let cands = names(&["a", "b", "c"]);
// --only b,c (a dropped even though it changed); skip-logic bypassed.
let s = select_republish(&cands, &set(&[]), &deps(&[]), &names(&["b", "c"]), &[], false);
assert_eq!(s.publish, names(&["b", "c"]));
// --exclude b: b dropped, a+c (changed) publish.
let s2 = select_republish(&cands, &set(&[]), &deps(&[]), &[], &names(&["b"]), false);
assert_eq!(s2.publish, names(&["a", "c"]));
assert!(s2.skipped.iter().any(|(c, r)| c == "b" && *r == SelectReason::Excluded));
}
#[test]
fn only_one_crate_yields_a_single_crate_publish_set() {
// Regression for the `--only nornir` bug: given a FULL-workspace candidate
// set (the ~95-crate cascade), `--only nornir` must collapse the publish set
// to EXACTLY `nornir` — no facett-*, no znippy-zoomies, no draupnir — and the
// drift-based selection is bypassed entirely (skip-logic does not apply).
let cands = names(&[
"funnel-core",
"draupnir",
"znippy-zoomies",
"facett-core",
"nornir",
"edda",
]);
// Pretend everything (incl. deps of nornir) drifted — still only nornir ships.
let s = select_republish(&cands, &set(&[]), &deps(&[]), &names(&["nornir"]), &[], false);
assert_eq!(s.publish, names(&["nornir"]), "--only nornir ⇒ exactly nornir");
assert_eq!(s.publish.len(), 1, "a 1-crate publish set");
assert!(s.will_publish("nornir"));
assert!(!s.will_publish("facett-core"));
assert!(!s.will_publish("znippy-zoomies"));
assert!(!s.will_publish("draupnir"));
}
#[test]
fn cascade_is_order_independent() {
// Dependents listed BEFORE their deps in the input — the fixpoint still
// pulls the whole chain in.
let cands = names(&["c", "b", "a"]);
let dep_edges = deps(&[("b", &["a"]), ("c", &["b"])]);
let s = select_republish(&cands, &set(&["b", "c"]), &dep_edges, &[], &[], false);
// a changed → b → c all publish, in the given order.
assert_eq!(s.publish, names(&["c", "b", "a"]));
assert!(s.skipped.is_empty());
}
#[test]
fn identical_maps_are_unchanged() {
let a = m(&[("src/lib.rs", b"fn x(){}"), ("README.md", b"hi")]);
let b = m(&[("src/lib.rs", b"fn x(){}"), ("README.md", b"hi")]);
assert_eq!(diff_source_maps(&a, &b), DriftVerdict::Unchanged);
}
#[test]
fn changed_file_is_drift() {
let local = m(&[("src/native.rs", b"lzip_parallel::decompress_zip_filter()")]);
let published = m(&[("src/native.rs", b"lzip_parallel::thread_pool()")]);
match diff_source_maps(&local, &published) {
DriftVerdict::Drifted { differing } => {
assert_eq!(differing, vec!["src/native.rs (changed)".to_string()]);
}
other => panic!("expected drift, got {other:?}"),
}
}
#[test]
fn added_and_removed_files_are_drift() {
let local = m(&[("src/lib.rs", b"x"), ("src/new.rs", b"y")]);
let published = m(&[("src/lib.rs", b"x"), ("src/gone.rs", b"z")]);
match diff_source_maps(&local, &published) {
DriftVerdict::Drifted { differing } => {
assert!(differing.contains(&"src/new.rs (added locally)".to_string()));
assert!(differing.contains(&"src/gone.rs (removed locally)".to_string()));
}
other => panic!("expected drift, got {other:?}"),
}
}
#[test]
fn manifest_reformatting_is_not_drift() {
// Same dependencies, different whitespace / key order → canonicalised equal.
let raw_a = b"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n";
let raw_b = b"[dependencies]\nserde = \"1\"\n[package]\nversion=\"0.1.0\"\nname=\"c\"\n";
let local = m(&[("Cargo.toml", &canonical_manifest(raw_a))]);
let published = m(&[("Cargo.toml", &canonical_manifest(raw_b))]);
assert_eq!(diff_source_maps(&local, &published), DriftVerdict::Unchanged);
}
#[test]
fn skip_only_when_published_and_unchanged() {
// The one skippable case: has a published version AND unchanged.
assert!(can_skip_republish(Some("1.2.3"), &DriftVerdict::Unchanged));
// First publish (no published max) → never skip, even if "unchanged".
assert!(!can_skip_republish(None, &DriftVerdict::Unchanged));
// Drift → never skip (bump required).
assert!(!can_skip_republish(
Some("1.2.3"),
&DriftVerdict::Drifted { differing: vec!["src/lib.rs (changed)".into()] }
));
// Inconclusive → never skip (bump, to be safe).
assert!(!can_skip_republish(Some("1.2.3"), &DriftVerdict::Unknown("network".into())));
}
#[test]
fn manifest_dependency_change_is_drift() {
let raw_a = b"[package]\nname=\"c\"\nversion=\"0.1.0\"\n[dependencies]\nserde=\"1\"\n";
let raw_b = b"[package]\nname=\"c\"\nversion=\"0.1.0\"\n[dependencies]\nserde=\"2\"\n";
let local = m(&[("Cargo.toml", &canonical_manifest(raw_a))]);
let published = m(&[("Cargo.toml", &canonical_manifest(raw_b))]);
assert!(matches!(diff_source_maps(&local, &published), DriftVerdict::Drifted { .. }));
}
/// Real-world E2E (network + a local checkout): assess a crate whose local
/// tree is known-consistent with its published version. Opt-in — set
/// `NORNIR_DRIFT_SMOKE_DIR` / `_KRATE` / `_VER` to a matching checkout; skips
/// cleanly if unset so CI never depends on network or a specific path.
#[test]
#[ignore = "network + local checkout; run explicitly with the env vars set"]
fn e2e_assess_against_published_crate() {
let (Ok(dir), Ok(krate), Ok(ver)) = (
std::env::var("NORNIR_DRIFT_SMOKE_DIR"),
std::env::var("NORNIR_DRIFT_SMOKE_KRATE"),
std::env::var("NORNIR_DRIFT_SMOKE_VER"),
) else {
eprintln!("skipped: set NORNIR_DRIFT_SMOKE_{{DIR,KRATE,VER}}");
return;
};
let v = assess(&CratesIoSources, Path::new(&dir), &krate, &ver);
assert_eq!(v, DriftVerdict::Unchanged, "expected consistent tree to be Unchanged, got {v:?}");
}
#[test]
fn extract_strips_prefix_and_drops_synthesized() {
// Build a tiny .crate (gz+tar) in-memory and round-trip it.
let mut tar_buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut add = |name: &str, data: &[u8]| {
let mut h = tar::Header::new_gnu();
h.set_size(data.len() as u64);
h.set_mode(0o644);
h.set_cksum();
builder.append_data(&mut h, name, data).unwrap();
};
add("foo-1.0.0/src/lib.rs", b"pub fn f() {}");
add("foo-1.0.0/Cargo.toml", b"normalized junk = true"); // dropped
add("foo-1.0.0/Cargo.toml.orig", b"[package]\nname=\"foo\"\nversion=\"1.0.0\"\n");
add("foo-1.0.0/.cargo_vcs_info.json", b"{\"git\":{\"sha1\":\"abc\"}}"); // dropped
builder.finish().unwrap();
}
let mut gz = Vec::new();
{
use std::io::Write;
let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
enc.write_all(&tar_buf).unwrap();
enc.finish().unwrap();
}
let map = extract_published_map(&gz).unwrap();
assert!(map.contains_key("src/lib.rs"));
assert!(map.contains_key("Cargo.toml")); // from .orig
assert!(!map.contains_key("Cargo.toml.orig"));
assert!(!map.contains_key(".cargo_vcs_info.json"));
assert_eq!(map.get("src/lib.rs").unwrap(), b"pub fn f() {}");
}
/// Lay down a one-crate workspace whose member declares `readme` at `readme_rel`.
/// The repo-root `README.md` carries `root_readme`; the crate dir gets whatever
/// `extra` says. Returns (tempdir, crate_dir).
fn scratch_pkg(
readme_rel: &str,
root_readme: &[u8],
extra: &[(&str, &[u8])],
) -> (tempfile::TempDir, std::path::PathBuf) {
let td = tempfile::tempdir().unwrap();
let root = td.path();
std::fs::write(root.join("README.md"), root_readme).unwrap();
std::fs::write(root.join("Cargo.toml"), b"[workspace]\nmembers = [\"pkg\"]\nresolver = \"2\"\n")
.unwrap();
let dir = root.join("pkg");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), b"pub fn f() {}").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"pkg\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\
description = \"d\"\nlicense = \"MIT\"\nreadme = \"{readme_rel}\"\n\n[dependencies]\n"
)
.as_bytes(),
)
.unwrap();
for (rel, bytes) in extra {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, bytes).unwrap();
}
(td, dir)
}
/// RED before the fix: `readme = "../README.md"` makes `cargo package --list`
/// print `README.md`, which does not exist at `<crate_dir>/README.md`, and the
/// naive join returned `Err(read …/pkg/README.md: No such file or directory)` —
/// the exact string that put six znippy crates in `Unknown` and out of the SKIP
/// set. Asserts the APPLIED bytes, not merely that the call succeeded: a
/// resolution that found some other README would still be wrong.
#[test]
fn out_of_package_readme_is_read_from_where_it_actually_lives() {
let (_td, dir) = scratch_pkg("../README.md", b"# repo root readme\n", &[]);
assert!(!dir.join("README.md").exists(), "precondition: not in the crate dir");
let map = local_shipped_sources(&dir).expect("out-of-package readme must resolve");
assert_eq!(
map.get("README.md").map(|v| v.as_slice()),
Some(b"# repo root readme\n".as_slice()),
"the SHIPPED bytes are the repo-root README cargo copies in"
);
assert!(map.contains_key("src/lib.rs"));
}
/// The override must not hijack an in-package readme. Here `docs/README.md`
/// exists inside the crate and is listed under that relative path; a bare
/// `README.md` key must NOT appear, and the file-name collision with the
/// repo-root README must not be resolved to the root's bytes.
#[test]
fn in_package_readme_is_untouched_by_the_relocation_map() {
let (_td, dir) =
scratch_pkg("docs/README.md", b"# WRONG root\n", &[("docs/README.md", b"# in-crate\n")]);
assert!(
out_of_package_asset_paths(&dir).is_empty(),
"an in-package readme is not a relocation"
);
let map = local_shipped_sources(&dir).unwrap();
assert_eq!(
map.get("docs/README.md").map(|v| v.as_slice()),
Some(b"# in-crate\n".as_slice())
);
assert!(!map.contains_key("README.md"), "no bare README.md key invented");
}
}
/// Resolve the cargo binary — lifted verbatim from nornir's `release::cargo` so
/// this crate stays a self-contained leaf. `$CARGO` → `~/.cargo/bin/cargo` →
/// `rustup which cargo` → `"cargo"`.
fn cargo_bin() -> std::ffi::OsString {
if let Some(c) = std::env::var_os("CARGO").filter(|c| !c.is_empty()) {
return c;
}
if let Some(home) = std::env::var_os("HOME") {
let p = Path::new(&home).join(".cargo/bin/cargo");
if p.exists() {
return p.into_os_string();
}
}
if let Ok(out) = Command::new("rustup").args(["which", "cargo"]).output() {
if out.status.success() {
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !p.is_empty() && Path::new(&p).exists() {
return std::ffi::OsString::from(p);
}
}
}
std::ffi::OsString::from("cargo")
}