diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index 95d910f23..0589fa780 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -4,7 +4,7 @@ use serde_json::json; use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::pdm_lock::{rewrite_pdm_lock_in, PdmLockParse}; +use crate::utils::pdm_lock::{rewrite_pdm_lock_all, LockBatch, LockStep, PdmLockDep, PdmLockParse}; pub(super) fn rewrite( files: &BTreeMap, @@ -14,28 +14,44 @@ pub(super) fn rewrite( let Some(original) = files.get("pdm.lock") else { return; }; - let mut text = original.clone(); - let mut stale_warned = false; - // Each lock state is parsed once: the presence probe, the rewrite, the - // format probe and the next dep all share it. + // Each dep's intake, in dep order: skipped (the lock lacks it), refused + // before the lock is read, or one dep of the batch rewrite. A package + // `pdm.lock` simply does not contain is not installed by pdm — a sibling + // `requirements.txt`/pylock may legitimately carry it — so we neither + // redirect it here nor veto the other pypi rewriters. Only a package the + // lock DOES contain but the plan refuses (source conflict, unsupported + // format, forked variants, bad hashes) withholds siblings. No rewrite + // adds or drops a package, so the probe reads the original lock. let mut parse = PdmLockParse::default(); - for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { - // A package `pdm.lock` simply does not contain is not installed by pdm — - // a sibling `requirements.txt`/pylock may legitimately carry it — so we - // neither redirect it here nor veto the other pypi rewriters. Only a - // package the lock DOES contain but the plan refuses (source conflict, - // unsupported format, forked variants, bad hashes) withholds siblings. - if !lock_contains(&mut parse, &text, &dep.name) { - continue; - } - match plan_in(&mut parse, &text, dep) { - Ok((rewritten, edits)) => { + let intake: Vec<(&DepOverride, Result)> = overrides + .iter() + .filter(|dep| dep.ecosystem == "pypi") + .filter(|dep| lock_contains(&mut parse, original, &dep.name)) + .map(|dep| (dep, artifact_of(dep))) + .collect(); + let lock_deps: Vec = intake + .iter() + .filter_map(|(dep, artifact)| Some(lock_dep(dep, artifact.as_ref().ok()?))) + .collect(); + // Every dep is rewritten over one parse and one render of the lock. + let LockBatch { text, steps } = rewrite_pdm_lock_all(original, &lock_deps); + let mut steps = steps.into_iter(); + // No rewrite touches `[metadata] lock_version`: read once, from the + // parse the presence probe already took. + let lock_ver: Option = parse.parsed(original).ok().and_then(|lock| { + crate::utils::pdm_lock::lock_version(lock) + .ok() + .map(str::to_string) + }); + let mut stale_warned = false; + for (dep, intake) in intake { + let step = match intake { + Ok(_) => steps.next().expect("one step per batched dep"), + Err(detail) => LockStep::Refused(detail), + }; + match step { + LockStep::Rewritten(_) | LockStep::Unchanged => { result.confirmed_pdm_uuids.insert(dep.patch_uuid.clone()); - let lock_ver: Option = parse.parsed(&rewritten).ok().and_then(|lock| { - crate::utils::pdm_lock::lock_version(lock) - .ok() - .map(str::to_string) - }); if lock_ver.as_deref() == Some("2") { result.warnings.push(RewriteWarning { code: "redirect_pdm_legacy_sync_required".into(), detail: "PDM 0.x may regenerate freshly generated locks during install; use `pdm sync` to preserve this patch, or upgrade PDM".into() }); } @@ -65,10 +81,14 @@ pub(super) fn rewrite( ), }); } - text = rewritten; - result.edits.extend(edits); + if let LockStep::Rewritten(edits) = step { + result + .edits + .extend(edits.into_iter().map(|(old, new)| file_edit(dep, old, new))); + } } - Err(detail) => { + LockStep::NotFound => unreachable!("PDM refuses a package its lock lacks"), + LockStep::Refused(detail) => { result.refused_pdm_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_pdm_refused".into(), @@ -106,17 +126,11 @@ fn lock_contains(parse: &mut PdmLockParse, text: &str, name: &str) -> bool { } } -#[cfg(test)] -fn plan(text: &str, dep: &DepOverride) -> Result<(String, Vec), String> { - plan_in(&mut PdmLockParse::default(), text, dep) -} +/// A dep's wheel filename and SHA-256. +type Artifact<'a> = (String, &'a str); -/// Plan `dep`'s rewrite of `text`, reusing (and refreshing) `parse`. -fn plan_in( - parse: &mut PdmLockParse, - text: &str, - dep: &DepOverride, -) -> Result<(String, Vec), String> { +/// `dep`'s [`Artifact`], or its refusal before the lock is read. +fn artifact_of(dep: &DepOverride) -> Result, String> { let sha256 = dep .integrity .sha256 @@ -133,28 +147,48 @@ fn plan_in( .path_segments() .and_then(|mut segments| segments.next_back()) .ok_or("missing PDM wheel filename")?; - let rewrite = rewrite_pdm_lock_in( - parse, - text, - &dep.name, - &dep.version, - ("url", &dep.artifact_url), + Ok((filename.to_string(), sha256)) +} + +/// `dep`'s arguments to the lock rewrite, given its [`artifact_of`]. +fn lock_dep<'a>(dep: &'a DepOverride, (filename, sha256): &'a Artifact<'a>) -> PdmLockDep<'a> { + PdmLockDep { + name: &dep.name, + version: &dep.version, + source: ("url", &dep.artifact_url), filename, sha256, - )?; - let edits = rewrite - .edits()? - .into_iter() - .map(|(old, new)| FileEdit { - path: "pdm.lock".into(), - kind: "redirect_pdm_lock_package".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(json!(old)), - new: Some(json!(new)), - }) - .collect(); - Ok((rewrite.text, edits)) + } +} + +fn file_edit(dep: &DepOverride, old: String, new: String) -> FileEdit { + FileEdit { + path: "pdm.lock".into(), + kind: "redirect_pdm_lock_package".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(json!(old)), + new: Some(json!(new)), + } +} + +/// `dep`'s rewrite of `text` alone: the rewritten text and its edits. +#[cfg(test)] +fn plan(text: &str, dep: &DepOverride) -> Result<(String, Vec), String> { + let artifact = artifact_of(dep)?; + let LockBatch { text, mut steps } = rewrite_pdm_lock_all(text, &[lock_dep(dep, &artifact)]); + match steps.remove(0) { + LockStep::Rewritten(edits) => Ok(( + text, + edits + .into_iter() + .map(|(old, new)| file_edit(dep, old, new)) + .collect(), + )), + LockStep::Unchanged => Ok((text, Vec::new())), + LockStep::NotFound => unreachable!("PDM refuses a package its lock lacks"), + LockStep::Refused(detail) => Err(detail), + } } #[cfg(test)] @@ -428,4 +462,56 @@ mod parse_reuse_equivalence_tests { assert!(confirmed > 100, "only {confirmed} confirmed"); g.finish(); } + + /// A dozen patched packages in one lock cost one whole-lock render and + /// re-parse, not one per package (#762). + #[test] + fn many_patches_render_the_lock_once() { + use crate::utils::lock_fragments::RENDERS; + let url = |name: &str| { + format!("https://patch.socket.dev/patch/pypi/{name}/a/{name}-1.26.18-py3-none-any.whl") + }; + let mut checked = 0; + for (fixture, lock) in fixtures() { + for crlf in [false, true] { + let mut lock = grown(&lock.replace("\r\n", "\n"), 11); + if crlf { + lock = lock.replace('\n', "\r\n"); + } + let names = std::iter::once("urllib3".to_string()) + .chain((0..11).map(|n| format!("pkg{n}"))); + let deps: Vec = names + .enumerate() + .map(|(n, name)| DepOverride { + ecosystem: "pypi".into(), + artifact_url: url(&name), + name, + namespace: None, + version: "1.26.18".into(), + token: String::new(), + patch_uuid: format!("00000000-0000-4000-8000-{n:012}"), + registry_override: None, + integrity: Integrity { + sha256: Some("a".repeat(64)), + ..Default::default() + }, + }) + .collect(); + let files = BTreeMap::from([("pdm.lock".to_string(), lock)]); + RENDERS.with(|renders| renders.set(0)); + let mut got = RewriteResult::default(); + rewrite(&files, &deps, &mut got); + if got.confirmed_pdm_uuids.len() != 12 { + continue; // a generation this dep shape doesn't land on + } + checked += 1; + assert_eq!( + RENDERS.with(|renders| renders.get()), + 1, + "{fixture} crlf={crlf}" + ); + } + } + assert!(checked >= 16, "only {checked} locks landed every dep"); + } } diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index 624b74c4a..c9826d935 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -9,7 +9,7 @@ use toml_edit::DocumentMut; use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; use crate::utils::poetry_lock::{ - generated_by_version, lock_version, rewrite_poetry_lock_in, PoetryLockParse, + generated_by_version, lock_version, rewrite_poetry_lock_all, LockBatch, LockStep, PoetryLockDep, }; /// Whether the lock was written by a Poetry release older than 1.4. Those @@ -56,52 +56,40 @@ pub(super) fn rewrite_poetry( } } for (path, original) in locks { - let mut content = original.clone(); + let deps: Vec = usable + .iter() + .map(|&(dep, sha256)| PoetryLockDep { + name: &dep.name, + version: &dep.version, + source_type: "url", + source_url: &dep.artifact_url, + filename: dep.artifact_url.rsplit('/').next().unwrap_or(""), + sha256, + }) + .collect(); + // Every dep is rewritten over one parse and one render of the lock. + let LockBatch { + text: content, + steps, + } = rewrite_poetry_lock_all(original, &deps); let mut stale_warned = false; // `pre_1_4_writer` reads only the first line and `[metadata] - // lock-version`, which no package rewrite touches: judged once, on the - // lock as of its first rewrite (where it was always first judged). + // lock-version`, which no package rewrite touches: judged once. let mut writer_format: Option> = None; - // Each lock state is parsed once: a rewrite hands its parsed output - // to the next dep. - let mut parse = PoetryLockParse::default(); - for &(dep, sha256) in &usable { - let filename = dep.artifact_url.rsplit('/').next().unwrap_or(""); - match rewrite_poetry_lock_in( - &mut parse, - &content, - &dep.name, - &dep.version, - "url", - &dep.artifact_url, - filename, - sha256, - ) { - Ok(Some(rewrite)) if rewrite.text != content => { - match rewrite.edits() { - Ok(edits) => { - for (original, new) in edits { - result.edits.push(FileEdit { - path: path.clone(), - kind: "redirect_poetry_lock_package".into(), - action: "rewritten".into(), - key: Some(format!("{}@{}", dep.name, dep.version)), - original: Some(Value::String(original)), - new: Some(Value::String(new)), - }); - } - } - Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); - result.warnings.push(RewriteWarning { - code: "redirect_poetry_lock_unsupported".into(), - detail: format!("{path}: {detail}"), - }); - continue; - } + for (&(dep, _), step) in usable.iter().zip(steps) { + match step { + LockStep::Rewritten(edits) => { + for (original, new) in edits { + result.edits.push(FileEdit { + path: path.clone(), + kind: "redirect_poetry_lock_package".into(), + action: "rewritten".into(), + key: Some(format!("{}@{}", dep.name, dep.version)), + original: Some(Value::String(original)), + new: Some(Value::String(new)), + }); } result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); - content = rewrite.text; if !stale_warned { if let Some(format) = *writer_format.get_or_insert_with(|| pre_1_4_writer(&content)) @@ -135,14 +123,14 @@ pub(super) fn rewrite_poetry( } } // Already redirected to this artifact (idempotent re-scan). - Ok(Some(_)) => { + LockStep::Unchanged => { result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); } - Ok(None) => result.warnings.push(RewriteWarning { + LockStep::NotFound => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), - Err(detail) => { + LockStep::Refused(detail) => { result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), @@ -274,4 +262,40 @@ mod equivalence_tests { } g.finish(); } + + /// A dozen patched packages in one lock cost one whole-lock render and + /// re-parse, not one per package (#760). + #[test] + fn many_patches_render_the_lock_once() { + use crate::utils::lock_fragments::RENDERS; + for version in VERSIONS.iter().filter(|version| !version.starts_with("0.")) { + for crlf in [false, true] { + let mut lock = grown(version, 11); + // Give every clone its own populated legacy integrity entry, + // as Poetry writes them. + if let Some(start) = lock.find("\nurllib3 = [\n") { + let end = start + lock[start..].find("\n]").unwrap() + 2; + let entry = lock[start..end].to_string(); + let clones: String = (0..11) + .map(|i| entry.replacen("urllib3 =", &format!("pkg{i} ="), 1)) + .collect(); + lock.insert_str(end, &clones); + } + if crlf { + lock = lock.replace('\n', "\r\n"); + } + let files = BTreeMap::from([("poetry.lock".to_string(), lock)]); + let mut deps = vec![dep("urllib3", "1.26.18", Some(SHA), 0)]; + deps.extend((0..11).map(|i| dep(&format!("pkg{i}"), "1.26.18", Some(SHA), i + 1))); + RENDERS.with(|renders| renders.set(0)); + let got = run(rewrite_poetry, &files, &deps); + assert_eq!(got.confirmed_python_lock_uuids.len(), 12, "{version}"); + assert_eq!( + RENDERS.with(|renders| renders.get()), + 1, + "{version} crlf={crlf}" + ); + } + } + } } diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/src/utils/lock_fragments.rs b/crates/socket-patch-core/src/utils/lock_fragments.rs index c78ca2004..354b08baa 100644 --- a/crates/socket-patch-core/src/utils/lock_fragments.rs +++ b/crates/socket-patch-core/src/utils/lock_fragments.rs @@ -13,6 +13,13 @@ use toml_edit::Table; use crate::utils::line_endings::majority_terminator; +#[cfg(test)] +thread_local! { + /// Whole-lock renders this thread's rewrites took, each followed by a + /// full re-parse: what a hosted rewrite of N deps must not pay N times. + pub(crate) static RENDERS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Takes `name`'s fragments of `text` from its (spanned) parse. pub(crate) type FragmentsIn = fn(&toml_edit::Document, &str, &str) -> Result, String>; @@ -212,6 +219,8 @@ pub(crate) fn finish<'a>( before: Result, String>, fragments_in: FragmentsIn, ) -> Result, String> { + #[cfg(test)] + RENDERS.with(|renders| renders.set(renders.get() + 1)); let before = before?; let rendered = crate::utils::python_lock::preserve_line_endings(text, rendered); let after_doc = toml_edit::Document::parse(rendered).map_err(|e| e.to_string())?; @@ -262,6 +271,166 @@ pub(crate) fn finish<'a>( }) } +/// What one dep's rewrite did to a lock, whether it ran in a +/// [`rewrite_batch`] or step by step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockStep { + /// The dep's unit changed: its `(original, replacement)` fragment edits. + Rewritten(Vec<(String, String)>), + /// The unit already carried this rewrite (an idempotent re-run). + Unchanged, + /// The lock has no unit for the dep (or locks another version). + NotFound, + /// The rewrite refused, leaving the lock as it was. + Refused(String), +} + +/// Every dep's [`LockStep`] over one lock, in dep order, and the lock text +/// after all of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockBatch { + pub text: String, + pub steps: Vec, +} + +/// Every dep's rewrite of `text` over ONE parse and ONE render, with exactly +/// the outcome the step-by-step rewrite (one [`finish`] per dep, each parsing +/// the previous dep's output) would have had; `None` whenever this cannot +/// vouch for that, and the caller then goes step by step. +/// +/// `plan(i, lock)` settles dep `i` against the document as the previous deps +/// left it, so a refusal or not-found verdict is the one the step-by-step +/// rewrite would reach; `mutate` then applies a planned dep. Each rewritten +/// dep's fragments are taken from the original text and from the single +/// rendering, and spliced into the original text. That is only the +/// step-by-step result when the deps rewrite distinct packages (a second +/// rewrite of a package would start from the first one's output) and the +/// lock has one line-ending style (a mixed lock's majority, which spells +/// each spliced fragment, can shift as deps land), and the combined splice +/// must reproduce the rendering byte for byte. +pub(crate) fn rewrite_batch

( + text: &str, + names: &[&str], + mut plan: impl FnMut(usize, &Table) -> Result, String>, + mut mutate: impl FnMut(&mut toml_edit::DocumentMut, P) -> Result<(), String>, + fragments_in: FragmentsIn, +) -> Option { + if text.contains("\r\n") && text.replace("\r\n", "").contains('\n') { + return None; + } + let original = toml_edit::Document::parse(text.to_owned()).ok()?; + let mut lock = original.clone().into_mut(); + let mut steps = Vec::with_capacity(names.len()); + let mut rewritten: Vec<(usize, Vec)> = Vec::new(); + let mut packages = std::collections::BTreeSet::new(); + for (index, name) in names.iter().enumerate() { + match plan(index, lock.as_table()) { + Err(detail) => steps.push(LockStep::Refused(detail)), + Ok(None) => steps.push(LockStep::NotFound), + Ok(Some(planned)) => { + if !packages.insert(crate::crawlers::python_crawler::canonicalize_pypi_name( + name, + )) { + return None; + } + let before = fragments_in(&original, text, name).ok()?; + mutate(&mut lock, planned).ok()?; + rewritten.push((index, before)); + steps.push(LockStep::Unchanged); + } + } + } + if rewritten.is_empty() { + return Some(LockBatch { + text: text.to_string(), + steps, + }); + } + #[cfg(test)] + RENDERS.with(|renders| renders.set(renders.get() + 1)); + let rendered = crate::utils::python_lock::preserve_line_endings(text, lock.to_string()); + let after_doc = toml_edit::Document::parse(rendered).ok()?; + let rendered = after_doc.raw(); + let file_terminator = majority_terminator(text); + // Each changed fragment as the byte range of `text` it replaces (trimmed + // to what differs) and the replacement. + let mut splices: Vec<(std::ops::Range, &str)> = Vec::new(); + let mut edits_of: Vec<(usize, Vec<(String, String)>)> = Vec::new(); + for (index, before) in &rewritten { + let after = fragments_in(&after_doc, rendered, names[*index]).ok()?; + if before.len() != after.len() { + return None; + } + let mut edits = Vec::new(); + for (old, new) in before.iter().zip(after) { + let new = respell(old, &new, file_terminator); + if *old == new { + continue; + } + if text.matches(old.as_str()).count() != 1 { + return None; + } + edits.push((old.clone(), new)); + } + edits_of.push((*index, edits)); + } + for (_, edits) in &edits_of { + for (old, new) in edits { + let at = text.find(old.as_str())?; + // Only the bytes that differ are replaced, so the units' shared + // boundaries (a unit's fragment ends at the next unit's header) + // never overlap. + let common = + |a: &mut dyn Iterator| a.take_while(|(x, y)| x == y).count(); + let mut prefix = common(&mut old.bytes().zip(new.bytes())); + while !old.is_char_boundary(prefix) || !new.is_char_boundary(prefix) { + prefix -= 1; + } + let mut suffix = + common(&mut old[prefix..].bytes().rev().zip(new[prefix..].bytes().rev())); + while !old.is_char_boundary(old.len() - suffix) + || !new.is_char_boundary(new.len() - suffix) + { + suffix -= 1; + } + splices.push(( + at + prefix..at + old.len() - suffix, + &new[prefix..new.len() - suffix], + )); + } + } + splices.sort_by_key(|(range, _)| (range.start, range.end)); + let mut result = String::with_capacity(rendered.len()); + let mut copied = 0; + for (range, replacement) in &splices { + if range.start < copied { + return None; + } + result.push_str(&text[copied..range.start]); + result.push_str(replacement); + copied = range.end; + } + result.push_str(&text[copied..]); + if result != rendered { + return None; + } + for (index, edits) in edits_of { + if edits + .iter() + .any(|(_, new)| result.matches(new.as_str()).count() != 1) + { + return None; + } + if !edits.is_empty() { + steps[index] = LockStep::Rewritten(edits); + } + } + Some(LockBatch { + text: result, + steps, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index dca8e88be..5149cd61f 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -4,8 +4,10 @@ use toml_edit::{value, Array, InlineTable, Item, Table, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::lock_fragments::{ - extend_span, finish, fragments_of, next_header_end, pair_fragments, FragmentRewrite, LockParse, + extend_span, finish, fragments_of, next_header_end, pair_fragments, rewrite_batch, + FragmentRewrite, LockParse, }; +pub use crate::utils::lock_fragments::{LockBatch, LockStep}; use crate::utils::python_lock::is_prior_hosted_url; pub fn lock_version(lock: &Table) -> Result<&str, String> { @@ -175,15 +177,7 @@ pub fn rewrite_pdm_lock_in<'a>( sha256: &str, ) -> Result, String> { let (kind, location) = source; - if !matches!(kind, "url" | "path") - || sha256.len() != 64 - || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("invalid PDM artifact source or SHA-256".into()); - } - if !crate::vendor::pypi_distribution::matches(filename, name, version) { - return Err("PDM patch wheel does not match package".into()); - } + check_pdm_artifact(name, version, kind, filename, sha256)?; let doc = parse.take(text, "PDM")?; let edits = match plan_pdm_rewrite(&doc, name, version, kind, location) { Ok(edits) => edits, @@ -197,6 +191,47 @@ pub fn rewrite_pdm_lock_in<'a>( // where a fresh parse would raise it, after the rewrite. let before = pdm_lock_fragments_in(&doc, text, name); let mut lock = doc.into_mut(); + mutate_pdm_lock(&mut lock, edits, source, filename, sha256)?; + finish( + "PDM", + parse, + text, + name, + lock.to_string(), + before, + pdm_lock_fragments_in::, + ) +} + +/// The rewrite's own refusals, settled before the lock is read. +fn check_pdm_artifact( + name: &str, + version: &str, + kind: &str, + filename: &str, + sha256: &str, +) -> Result<(), String> { + if !matches!(kind, "url" | "path") + || sha256.len() != 64 + || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("invalid PDM artifact source or SHA-256".into()); + } + if !crate::vendor::pypi_distribution::matches(filename, name, version) { + return Err("PDM patch wheel does not match package".into()); + } + Ok(()) +} + +/// Apply a planned rewrite (the units [`plan_pdm_rewrite`] settled) to the +/// parsed lock. +fn mutate_pdm_lock( + lock: &mut toml_edit::DocumentMut, + edits: Vec<(usize, bool, String)>, + (kind, location): (&str, &str), + filename: &str, + sha256: &str, +) -> Result<(), String> { for (index, inline_files, files_key) in edits { let mut file = InlineTable::new(); file.insert("file", Value::from(filename)); @@ -223,17 +258,90 @@ pub fn rewrite_pdm_lock_in<'a>( table.insert(&files_key, value(files)); } } - finish( - "PDM", - parse, + Ok(()) +} + +/// One dep of a [`rewrite_pdm_lock_all`]: the arguments of +/// [`rewrite_pdm_lock_in`] past the lock text. +pub struct PdmLockDep<'a> { + pub name: &'a str, + pub version: &'a str, + pub source: (&'a str, &'a str), + pub filename: &'a str, + pub sha256: &'a str, +} + +/// Every dep's [`rewrite_pdm_lock_in`] over `text`, each against the +/// previous one's output: one parse and one render of the lock for all of +/// them when the batch can vouch for the step-by-step result, else step by +/// step. A dep's step is never [`LockStep::NotFound`]: PDM refuses a +/// package its lock lacks. +pub fn rewrite_pdm_lock_all(text: &str, deps: &[PdmLockDep]) -> LockBatch { + rewrite_pdm_lock_batch(text, deps).unwrap_or_else(|| rewrite_pdm_lock_steps(text, deps)) +} + +/// [`rewrite_pdm_lock_all`] over one parse and one render, or `None` (see +/// [`rewrite_batch`]). +fn rewrite_pdm_lock_batch(text: &str, deps: &[PdmLockDep]) -> Option { + let names: Vec<&str> = deps.iter().map(|dep| dep.name).collect(); + rewrite_batch( text, - name, - lock.to_string(), - before, + &names, + |index, lock| { + let dep = &deps[index]; + check_pdm_artifact( + dep.name, + dep.version, + dep.source.0, + dep.filename, + dep.sha256, + )?; + let units = plan_pdm_rewrite(lock, dep.name, dep.version, dep.source.0, dep.source.1)?; + Ok(Some((units, index))) + }, + |lock, (units, index)| { + let dep = &deps[index]; + mutate_pdm_lock(lock, units, dep.source, dep.filename, dep.sha256) + }, pdm_lock_fragments_in::, ) } +/// [`rewrite_pdm_lock_all`] one dep at a time. +fn rewrite_pdm_lock_steps(text: &str, deps: &[PdmLockDep]) -> LockBatch { + let mut content = text.to_string(); + let mut parse = PdmLockParse::default(); + let mut steps = Vec::with_capacity(deps.len()); + for dep in deps { + let rewrite = rewrite_pdm_lock_in( + &mut parse, + &content, + dep.name, + dep.version, + dep.source, + dep.filename, + dep.sha256, + ); + let step = match rewrite.and_then(|rewrite| Ok((rewrite.edits()?, rewrite.text))) { + Ok((edits, rewritten)) => { + let step = if rewritten == content { + LockStep::Unchanged + } else { + LockStep::Rewritten(edits) + }; + content = rewritten; + step + } + Err(detail) => LockStep::Refused(detail), + }; + steps.push(step); + } + LockBatch { + text: content, + steps, + } +} + /// Every refusal of [`rewrite_pdm_lock_in`], read from the parsed lock before /// it mutates anything: the `(package index, inline files, legacy files key)` /// of each unit to rewrite. @@ -1056,3 +1164,144 @@ pub(crate) mod parse_reuse_tests { assert!(names(&mut parse, &first.text).contains(&"pkg1".to_string())); } } + +#[cfg(test)] +mod batch_equivalence_tests { + //! [`rewrite_pdm_lock_all`]'s one-render batch against the step-by-step + //! rewrite it replaces (#762): whenever the batch answers, its text and + //! every dep's step are the step-by-step ones. + use super::parse_reuse_tests::{fixtures, grown}; + use super::*; + + const SHA: &str = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; + + struct Dep { + name: String, + version: &'static str, + kind: &'static str, + location: String, + sha256: String, + } + + fn dep(name: &str, version: &'static str, tag: &str) -> Dep { + Dep { + name: name.into(), + version, + kind: "url", + location: format!( + "https://patch.socket.dev/patch/pypi/{name}/{tag}/{name}-{version}-py3-none-any.whl" + ), + sha256: SHA.into(), + } + } + + fn lock_deps(deps: &[Dep]) -> Vec> { + deps.iter() + .map(|dep| PdmLockDep { + name: &dep.name, + version: dep.version, + source: (dep.kind, &dep.location), + filename: dep.location.rsplit('/').next().unwrap(), + sha256: &dep.sha256, + }) + .collect() + } + + /// The dep mixes run over each lock: every package (adjacent units), + /// every other one, reversed, interleaved with refusals, and a package + /// rewritten twice (which the batch hands back). + fn mixes(extra: usize) -> Vec<(Vec, bool)> { + let all = || { + std::iter::once(dep("urllib3", "1.26.18", "a")) + .chain((0..extra).map(|i| dep(&format!("pkg{i}"), "1.26.18", "a"))) + }; + let mut every_other: Vec = all().step_by(2).collect(); + every_other.push(dep("absent", "1.0.0", "a")); + let mut reversed: Vec = all().collect(); + reversed.reverse(); + let mut mixed: Vec = vec![dep("urllib3", "9.9.9", "a")]; + for (n, next) in all().enumerate() { + mixed.push(next); + if n == 1 { + mixed.push(Dep { + sha256: "not-a-sha".into(), + ..dep("pkg0", "1.26.18", "b") + }); + mixed.push(Dep { + kind: "path", + location: "./.socket/vendor/pypi/u/pkg0-1.26.18-py3-none-any.whl".into(), + ..dep("pkg0", "1.26.18", "a") + }); + } + } + mixed.push(dep("urllib3", "9.9.9", "a")); + let mut twice: Vec = all().collect(); + twice.push(dep("urllib3", "1.26.18", "rotated")); + vec![ + (all().collect(), true), + (every_other, true), + (reversed, true), + (mixed, true), + (twice, false), + ] + } + + #[test] + fn batch_matches_the_step_by_step_rewrite() { + let mut batched = 0; + let mut rendered = 0; + for (fixture, lock) in fixtures() { + for extra in [0, 1, 4] { + for style in ["lf", "crlf", "mixed"] { + let mut lock = grown(&lock.replace("\r\n", "\n"), extra); + match style { + "crlf" => lock = lock.replace('\n', "\r\n"), + "mixed" => lock = lock.replacen('\n', "\r\n", 1), + _ => {} + } + for (mix, (deps, batchable)) in mixes(extra).into_iter().enumerate() { + let deps = lock_deps(&deps); + let what = format!("{fixture} extra={extra} {style} mix={mix}"); + let first = rewrite_pdm_lock_steps(&lock, &deps); + let lands = first + .steps + .iter() + .any(|step| matches!(step, LockStep::Rewritten(_))); + // And again over the output: the idempotent re-scan. + for (rerun, text) in + [lock.clone(), first.text.clone()].into_iter().enumerate() + { + let steps = rewrite_pdm_lock_steps(&text, &deps); + let batch = rewrite_pdm_lock_batch(&text, &deps); + if let Some(batch) = &batch { + batched += 1; + rendered += usize::from(lands); + assert_eq!(batch, &steps, "{what}"); + } + assert_eq!(rewrite_pdm_lock_all(&text, &deps), steps, "{what}"); + if !lands { + continue; // an unsupported generation: all refused + } + if style == "mixed" { + // (The first rewrite may respell the lone CRLF + // line, leaving the re-run's lock all LF.) + assert!( + rerun == 1 || batch.is_none(), + "{what}: the batch must hand back" + ); + } else if batchable { + assert!(batch.is_some(), "{what}: the batch must answer"); + } else { + assert!(batch.is_none(), "{what}: the batch must hand back"); + } + } + } + } + } + } + assert!( + rendered > 200, + "only {rendered} landing cases batched ({batched})" + ); + } +} diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index 39f3e70f8..00b34db1e 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -16,8 +16,10 @@ use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, TableLike, use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::lock_fragments::{ - extend_span, finish, fragments_of, next_header_end, pair_fragments, FragmentRewrite, LockParse, + extend_span, finish, fragments_of, next_header_end, pair_fragments, rewrite_batch, + FragmentRewrite, LockParse, }; +pub use crate::utils::lock_fragments::{LockBatch, LockStep}; use crate::utils::python_lock::{is_prior_hosted_url, table_likes}; /// The `{file, hash}` tables Poetry records in `package`'s own @@ -250,18 +252,7 @@ pub fn rewrite_poetry_lock_in<'a>( filename: &str, sha256: &str, ) -> Result>, String> { - if !matches!(source_type, "file" | "url") - || sha256.len() != 64 - || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("invalid Poetry artifact source or SHA-256".into()); - } - // Poetry compares the lock's `sha256:` against `hashlib`'s lowercase - // hexdigest as strings, so an uppercase digest would fail every install. - let sha256 = sha256.to_ascii_lowercase(); - if !crate::vendor::pypi_distribution::matches(filename, name, version) { - return Err("Poetry patch wheel does not match the locked package".into()); - } + let sha256 = checked_sha256(name, version, source_type, filename, sha256)?; let doc = parse.take(text, "Poetry")?; let plan = match plan_poetry_rewrite(&doc, name, version, source_type, source_url, &sha256) { Ok(Some(plan)) => plan, @@ -271,16 +262,60 @@ pub fn rewrite_poetry_lock_in<'a>( return verdict.map(|_| None); } }; + // The original's fragments come from the same parse; an error surfaces + // where a fresh parse would raise it, after the rewrite. + let before = poetry_lock_fragments_in(&doc, text, name); + let mut lock = doc.into_mut(); + mutate_poetry_lock(&mut lock, plan, source_type, filename, &sha256)?; + finish( + "Poetry", + parse, + text, + name, + lock.to_string(), + before, + poetry_lock_fragments_in::, + ) + .map(Some) +} + +/// The rewrite's own refusals, settled before the lock is read: the +/// lowercase `sha256` to pin. +fn checked_sha256( + name: &str, + version: &str, + source_type: &str, + filename: &str, + sha256: &str, +) -> Result { + if !matches!(source_type, "file" | "url") + || sha256.len() != 64 + || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("invalid Poetry artifact source or SHA-256".into()); + } + if !crate::vendor::pypi_distribution::matches(filename, name, version) { + return Err("Poetry patch wheel does not match the locked package".into()); + } + // Poetry compares the lock's `sha256:` against `hashlib`'s lowercase + // hexdigest as strings, so an uppercase digest would fail every install. + Ok(sha256.to_ascii_lowercase()) +} + +/// Apply a planned rewrite to the parsed lock. +fn mutate_poetry_lock( + lock: &mut DocumentMut, + plan: PoetryLockPlan, + source_type: &str, + filename: &str, + sha256: &str, +) -> Result<(), String> { let PoetryLockPlan { format, effective_url, index, package_name, } = plan; - // The original's fragments come from the same parse; an error surfaces - // where a fresh parse would raise it, after the rewrite. - let before = poetry_lock_fragments_in(&doc, text, name); - let mut lock = doc.into_mut(); let package = lock .get_mut("package") .and_then(Item::as_array_of_tables_mut) @@ -325,23 +360,103 @@ pub fn rewrite_poetry_lock_in<'a>( .ok_or_else(|| format!("[metadata.{field}] is not a table"))?; if format == "0" { let mut hashes = Array::new(); - hashes.push(sha256.as_str()); + hashes.push(sha256); table.insert(&package_name, value(hashes)); } else { let entry = legacy_files_entry(table, &package_name, files, rewritten); table.insert(&package_name, entry); } } - finish( - "Poetry", - parse, + Ok(()) +} + +/// One dep of a [`rewrite_poetry_lock_all`]: the arguments of +/// [`rewrite_poetry_lock_in`] past the lock text. +pub struct PoetryLockDep<'a> { + pub name: &'a str, + pub version: &'a str, + pub source_type: &'a str, + pub source_url: &'a str, + pub filename: &'a str, + pub sha256: &'a str, +} + +/// Every dep's [`rewrite_poetry_lock_in`] over `text`, each against the +/// previous one's output: one parse and one render of the lock for all of +/// them when the batch can vouch for the step-by-step result, else step by +/// step. +pub fn rewrite_poetry_lock_all(text: &str, deps: &[PoetryLockDep]) -> LockBatch { + rewrite_poetry_lock_batch(text, deps).unwrap_or_else(|| rewrite_poetry_lock_steps(text, deps)) +} + +/// [`rewrite_poetry_lock_all`] over one parse and one render, or `None` (see +/// [`rewrite_batch`]). +fn rewrite_poetry_lock_batch(text: &str, deps: &[PoetryLockDep]) -> Option { + let names: Vec<&str> = deps.iter().map(|dep| dep.name).collect(); + rewrite_batch( text, - name, - lock.to_string(), - before, + &names, + |index, lock| { + let dep = &deps[index]; + let sha256 = checked_sha256( + dep.name, + dep.version, + dep.source_type, + dep.filename, + dep.sha256, + )?; + let plan = plan_poetry_rewrite( + lock, + dep.name, + dep.version, + dep.source_type, + dep.source_url, + &sha256, + )?; + Ok(plan.map(|plan| (plan, index, sha256))) + }, + |lock, (plan, index, sha256)| { + let dep = &deps[index]; + mutate_poetry_lock(lock, plan, dep.source_type, dep.filename, &sha256) + }, poetry_lock_fragments_in::, ) - .map(Some) +} + +/// [`rewrite_poetry_lock_all`] one dep at a time. +fn rewrite_poetry_lock_steps(text: &str, deps: &[PoetryLockDep]) -> LockBatch { + let mut content = text.to_string(); + let mut parse = PoetryLockParse::default(); + let mut steps = Vec::with_capacity(deps.len()); + for dep in deps { + let step = match rewrite_poetry_lock_in( + &mut parse, + &content, + dep.name, + dep.version, + dep.source_type, + dep.source_url, + dep.filename, + dep.sha256, + ) { + Ok(Some(rewrite)) if rewrite.text != content => match rewrite.edits() { + Ok(edits) => { + let text = rewrite.text; + content = text; + LockStep::Rewritten(edits) + } + Err(detail) => LockStep::Refused(detail), + }, + Ok(Some(_)) => LockStep::Unchanged, + Ok(None) => LockStep::NotFound, + Err(detail) => LockStep::Refused(detail), + }; + steps.push(step); + } + LockBatch { + text: content, + steps, + } } /// Every refusal and not-applicable verdict of [`rewrite_poetry_lock_in`] @@ -1082,3 +1197,182 @@ mod parse_reuse_equivalence_tests { assert!(matches!(got, Ok(None)), "the stale parse was reused"); } } + +#[cfg(test)] +mod batch_equivalence_tests { + //! [`rewrite_poetry_lock_all`]'s one-render batch against the + //! step-by-step rewrite it replaces (#760): whenever the batch answers, + //! its text and every dep's step are the step-by-step ones. + use super::*; + + const VERSIONS: &[&str] = &[ + "0.12.17", "1.0.10", "1.1.15", "1.2.2", "1.3.2", "1.4.2", "1.5.1", "1.6.1", "1.7.1", + "1.8.5", "2.0.1", "2.1.4", "2.2.1", "2.3.4", "2.4.3", + ]; + const SHA: &str = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; + + /// The native fixture with `extra` clones of its urllib3 unit, adjacent + /// to it, each with its own legacy integrity entry. + fn grown(version: &str, extra: usize) -> String { + let lock = std::fs::read_to_string(format!( + "{}/tests/fixtures/poetry/{version}/poetry.lock", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + .replace("\r\n", "\n"); + let meta = lock.find("\n[metadata]").unwrap(); + let first = lock.find("[[package]]").unwrap(); + let unit = &lock[first..meta]; + let mut out = lock[..meta].to_string(); + for i in 0..extra { + out.push('\n'); + out.push_str(&unit.replace("name = \"urllib3\"", &format!("name = \"pkg{i}\""))); + } + let mut tail = lock[meta..].to_string(); + for key in ["\nurllib3 = [\n", "\nurllib3 = []"] { + if let Some(start) = tail.find(key) { + let end = start + tail[start + 1..].find('\n').unwrap() + 1; + let end = if key.ends_with("[\n") { + start + tail[start..].find("\n]").unwrap() + 2 + } else { + end + }; + let entry = tail[start..end].to_string(); + let clones: String = (0..extra) + .map(|i| entry.replacen("urllib3 =", &format!("pkg{i} ="), 1)) + .collect(); + tail.insert_str(end, &clones); + break; + } + } + out + &tail + } + + struct Dep { + name: String, + version: &'static str, + source_type: &'static str, + url: String, + sha256: String, + } + + fn dep(name: &str, version: &'static str, tag: &str) -> Dep { + Dep { + name: name.into(), + version, + source_type: "url", + url: format!( + "https://patch.socket.dev/patch/pypi/{name}/{tag}/{name}-{version}-py2.py3-none-any.whl" + ), + sha256: SHA.into(), + } + } + + fn lock_deps(deps: &[Dep]) -> Vec> { + deps.iter() + .map(|dep| PoetryLockDep { + name: &dep.name, + version: dep.version, + source_type: dep.source_type, + source_url: &dep.url, + filename: dep.url.rsplit('/').next().unwrap(), + sha256: &dep.sha256, + }) + .collect() + } + + /// The dep mixes run over each lock: every package (adjacent units), + /// every other one, reversed, interleaved with refusals and not-found + /// verdicts, and a package rewritten twice (which the batch hands back). + fn mixes(extra: usize) -> Vec<(Vec, bool)> { + let all = || { + std::iter::once(dep("urllib3", "1.26.18", "a")) + .chain((0..extra).map(|i| dep(&format!("pkg{i}"), "1.26.18", "a"))) + }; + let mut every_other: Vec = all().step_by(2).collect(); + every_other.push(dep("absent", "1.0.0", "a")); + let mut reversed: Vec = all().collect(); + reversed.reverse(); + let mut mixed: Vec = vec![dep("urllib3", "9.9.9", "a")]; + for (n, dep) in all().enumerate() { + mixed.push(dep); + if n == 1 { + mixed.push(Dep { + sha256: "not-a-sha".into(), + ..super::batch_equivalence_tests::dep("pkg0", "1.26.18", "b") + }); + mixed.push(Dep { + source_type: "file", + url: ".socket/vendor/x/pkg0-1.26.18-py2.py3-none-any.whl".into(), + ..super::batch_equivalence_tests::dep("pkg0", "1.26.18", "a") + }); + } + } + mixed.push(dep("urllib3", "9.9.9", "a")); + let mut twice: Vec = all().collect(); + twice.push(dep("urllib3", "1.26.18", "rotated")); + vec![ + (all().collect(), true), + (every_other, true), + (reversed, true), + (mixed, true), + (twice, false), + ] + } + + #[test] + fn batch_matches_the_step_by_step_rewrite() { + let mut batched = 0; + let mut cases = 0; + for version in VERSIONS { + for extra in [0, 1, 4] { + for style in ["lf", "crlf", "mixed"] { + let mut lock = grown(version, extra); + match style { + "crlf" => lock = lock.replace('\n', "\r\n"), + "mixed" => lock = lock.replacen('\n', "\r\n", 1), + _ => {} + } + for (mix, (deps, batchable)) in mixes(extra).into_iter().enumerate() { + let deps = lock_deps(&deps); + let what = format!("{version} extra={extra} {style} mix={mix}"); + let steps = rewrite_poetry_lock_steps(&lock, &deps); + // And again over the output: the idempotent re-scan. + for (rerun, text) in + [lock.clone(), steps.text.clone()].into_iter().enumerate() + { + cases += 1; + let steps = rewrite_poetry_lock_steps(&text, &deps); + let batch = rewrite_poetry_lock_batch(&text, &deps); + if let Some(batch) = &batch { + batched += 1; + assert_eq!(batch, &steps, "{what}"); + } + assert_eq!(rewrite_poetry_lock_all(&text, &deps), steps, "{what}"); + // Poetry 0.12 refuses every URL source: nothing to + // render, so the batch answers. + if style == "mixed" { + // (The first rewrite may respell the lone CRLF + // line, leaving the re-run's lock all LF.) + assert!( + rerun == 1 || batch.is_none(), + "{what}: the batch must hand back" + ); + } else if version.starts_with("0.") { + assert!(batch.is_some(), "{what}: nothing rewritten"); + } else if !batchable { + assert!(batch.is_none(), "{what}: the batch must hand back"); + } else { + assert!(batch.is_some(), "{what}: the batch must answer"); + } + } + } + } + } + } + assert!( + batched * 2 > cases, + "only {batched} of {cases} cases batched" + ); + } +}