Never crash: the fuzz job's eleven hangs finish (parser retries, inline images, Type 3 fan-out)

Every hang the nightly fuzz job found (11 findings, 8 source files) now
finishes in 0.1-2 s; each cause has a synthetic regression test that times
out without its fix.
- cos (7 findings, our own parser): a stream whose /Length pointed at
  itself, with no endstream, made every parse attempt fail and each attempt
  reload the same object: ~2^32 parses. Objects being loaded are a seen-set,
  /Length values are memoised per load, and the shifted-header retry only
  runs when the header is shifted.
- hayro-syntax (3): deciding where an inline image's data ends re-parsed the
  rest of the stream, nesting through every inline image; 50 images ran for
  over ten minutes. The look-ahead no longer nests and is bounded (256).
- hayro-interpret (1): a Type 3 glyph showing eight glyphs of its own font,
  sixteen deep. Nested paints share a per-page budget (50,000).
The pdf.js corpus sweep matches its baseline (963 ok, same 20 known).
xtask check writes its report under CARGO_TARGET_DIR too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 07:35:38 -04:00
co-authored by Claude Opus 5.5
parent f97684f0d8
commit 2f364665b8
8 changed files with 182 additions and 30 deletions
+60 -18
View File
@@ -13,21 +13,29 @@ use crate::object::{Dict, ObjRef, Object};
use crate::parser::{Lexer, is_whitespace, parse_indirect};
thread_local! {
static LOAD_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
static LOADING: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
/// retries a parse several ways, and each retry would resolve the same lengths again
/// (exponential in the chain of streams whose lengths point at further streams).
static LENGTHS: std::cell::RefCell<HashMap<u32, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
}
/// Nesting counter for object loads on this thread (see `Document::try_get`).
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is
/// bounded as well.
struct LoadGuard;
impl LoadGuard {
const MAX: u32 = 32;
const MAX: usize = 32;
fn enter() -> Option<Self> {
LOAD_DEPTH.with(|d| {
if d.get() >= Self::MAX {
fn enter(num: u32) -> Option<Self> {
LOADING.with(|l| {
let mut l = l.borrow_mut();
if l.len() >= Self::MAX || l.contains(&num) {
return None;
}
d.set(d.get() + 1);
l.push(num);
Some(LoadGuard)
})
}
@@ -35,7 +43,14 @@ impl LoadGuard {
impl Drop for LoadGuard {
fn drop(&mut self) {
LOAD_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
let outermost = LOADING.with(|l| {
let mut l = l.borrow_mut();
l.pop();
l.is_empty()
});
if outermost {
LENGTHS.with(|m| m.borrow_mut().clear());
}
}
}
@@ -471,7 +486,7 @@ impl Document {
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
let _guard =
LoadGuard::enter().ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
let obj = self.load(num, 0)?;
let obj = Arc::new(obj);
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
@@ -485,16 +500,24 @@ impl Document {
match self.entries.get(&num) {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| self.try_get(r.num).ok().and_then(|o| o.as_int());
let resolve = |r: ObjRef| {
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
return known;
}
let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r.num, length));
length
};
let off = *offset as usize;
match parse_indirect(&self.data, off, &resolve) {
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
// Offsets relative to a shifted header, or simply wrong: try both fixes.
_ => match parse_indirect(&self.data, off + self.header_offset, &resolve) {
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
_ => self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num)),
},
}
let at = |off: usize| match parse_indirect(&self.data, off, &resolve) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
_ => None,
};
// Offsets relative to a shifted header, or simply wrong: try both fixes.
at(off)
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
.ok_or(CosError::MissingObject(num))
}
Some(XrefEntry::InStream { stream, index }) => {
let stm = self.objstm(*stream)?;
@@ -1082,4 +1105,23 @@ mod tests {
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log());
}
#[test]
fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
// From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream`
// after it. Every parse attempt failed, and each attempt resolved the length by loading
// object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses.
// Objects being loaded are now a seen-set, lengths are memoised, and the second parse
// only runs when the header is shifted.
let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok(doc) = Document::open(Arc::new(bytes)) {
let _ = doc.try_get(6);
}
let _ = tx.send(());
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
}
+53
View File
@@ -868,6 +868,59 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap");
}
/// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own
/// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen
/// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs,
/// tiling patterns inside one another) share a per-page budget.
#[test]
fn type3_glyphs_that_fan_out_into_themselves_finish() {
let pdf = "%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 53 >> stream
BT /F1 20 Tf 10 10 Td (A) Tj ET 1 0 0 rg 0 0 4 4 re f
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1]
/Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj
6 0 obj << /Length 48 >> stream
1 0 d0 BT /F1 1 Tf (AAAAAAAA) Tj ET 0 0 1 1 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a self-multiplying Type 3 glyph must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed
/// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the
/// data, the parser re-read the rest of the stream as content, and that re-read did the same
/// for every inline image it met, nesting through the whole stream: 50 images (3 KB) ran for
/// over ten minutes. Vendored hayro-syntax patch: the re-read doesn't nest, and the search is
/// bounded.
#[test]
fn inline_images_with_ei_in_their_data_parse_in_linear_time() {
let image = b"q 1 0 0 1 0 0 cm\nBI\n/IM true\n/W 8\n/H 2\n/BPC 1\nID \x01 EI \x02\nEI Q\n";
let content: Vec<u8> = image.iter().copied().cycle().take(image.len() * 200).collect();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut iter = hayro::hayro_syntax::content::TypedIter::new(&content);
let mut ops = 0usize;
while iter.next().is_some() {
ops += 1;
}
let _ = tx.send(ops);
});
let ops = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("inline images must not stall the parser");
assert!(ops >= 200 * 4, "every image and its q/cm/Q are read: {ops}");
}
/// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data
/// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`).
#[test]
+2 -2
View File
@@ -7,10 +7,10 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding` |
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish` |
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render` |
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows); (2) predictor row widths use saturating arithmetic | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time` |
Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`.
+33
View File
@@ -23,6 +23,32 @@ pub(crate) const MAX_NESTED_INTERPRETATION_DEPTH: u32 = 50;
/// inherits, which recursed until the stack overflowed (found by fuzzing).
pub(crate) const MAX_PAINT_NESTING: u32 = 16;
/// PrintCraft patch: how many nested paints (a form inside a form, a Type 3 glyph or tiling
/// pattern painted from inside another one) one page may do. Nesting depth alone doesn't bound
/// the work: a glyph that shows ten glyphs of its own font, nested sixteen deep, is 10^16
/// paints (a fuzzed file hung). Ordinary pages stay far below this.
pub(crate) const MAX_NESTED_PAINTS: u32 = 50_000;
std::thread_local! {
static NESTED_PAINTS: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
}
/// PrintCraft patch: count a paint at `depth` (top-level ones are free); `false` once this
/// page's budget is spent, and the paint is skipped.
pub(crate) fn take_nested_paint(depth: u32) -> bool {
if depth < 2 {
return true;
}
NESTED_PAINTS.with(|n| {
let used = n.get();
if used >= MAX_NESTED_PAINTS {
return false;
}
n.set(used + 1);
true
})
}
/// A cache used by the interpreter.
///
/// Ideally, such a cache should be constructed once per PDF and then reused across
@@ -75,6 +101,8 @@ impl<'a> Context<'a> {
settings: InterpreterSettings,
) -> Self {
let state = State::new(initial_transform);
// PrintCraft patch: a page (or other top-level content) starts with a fresh budget.
NESTED_PAINTS.with(|n| n.set(0));
Self::new_with(initial_transform, bbox, cache, xref, settings, state, 0)
}
@@ -301,6 +329,11 @@ impl<'a> Context<'a> {
return false;
}
if !take_nested_paint(self.nesting_depth + 1) {
warn!("nested paint budget exceeded");
return false;
}
self.nesting_depth += 1;
+3 -1
View File
@@ -125,7 +125,9 @@ impl<'a> Type3<'a> {
) -> Option<()> {
// PrintCraft patch: a glyph procedure may show text in this same font (through
// inherited resources), recursing until the stack overflowed.
if glyph.nesting_depth > crate::context::MAX_PAINT_NESTING {
if glyph.nesting_depth > crate::context::MAX_PAINT_NESTING
|| !crate::context::take_nested_paint(glyph.nesting_depth)
{
return None;
}
let mut state = glyph.state.clone();
+2 -2
View File
@@ -231,8 +231,8 @@ impl<'a> TilingPattern<'a> {
// PrintCraft patch: a tiling pattern can paint itself (directly, or through resources it
// inherits when its own are missing), which recursed until the stack overflowed. Bound
// the nesting like XObjects do, tighter because every level rasterizes a tile.
if self.nesting_depth > crate::context::MAX_PAINT_NESTING {
warn!("tiling pattern nesting depth exceeded");
if self.nesting_depth > crate::context::MAX_PAINT_NESTING || !crate::context::take_nested_paint(self.nesting_depth) {
warn!("tiling pattern nesting depth or paint budget exceeded");
return None;
}
let state = State::new(initial_transform);
+20 -2
View File
@@ -91,12 +91,22 @@ impl<'a> Readable<'a> for Operator<'a> {
}
}
/// PrintCraft patch: how many `EI` candidates an inline image examines, and how far past each
/// one the end-of-data heuristic looks, before taking the current candidate. Unbounded, the
/// heuristic re-parsed the rest of the stream for every candidate, and each re-parse ran the
/// heuristic again on the inline images it met: a fuzzed file hung for minutes.
const MAX_EI_CANDIDATES: u32 = 256;
const MAX_EI_LOOKAHEAD_STEPS: u32 = 256;
/// An iterator over operators in the PDF content streams, providing raw access to the instructions.
#[derive(Clone)]
pub struct UntypedIter<'a> {
reader: Reader<'a>,
stack: Stack<'a>,
operator: Option<Operator<'a>>,
/// PrintCraft patch: this iterator only checks whether data reads as content (the
/// end-of-data heuristic below); inline images it meets end at their first `EI`.
lookahead: bool,
}
impl<'a> UntypedIter<'a> {
@@ -106,6 +116,7 @@ impl<'a> UntypedIter<'a> {
reader: Reader::new(data),
stack: Stack::new(),
operator: None,
lookahead: false,
}
}
@@ -115,6 +126,7 @@ impl<'a> UntypedIter<'a> {
reader: Reader::new(&[]),
stack: Stack::new(),
operator: None,
lookahead: false,
}
}
@@ -168,6 +180,7 @@ impl<'a> UntypedIter<'a> {
let stream_data = self.reader.tail()?;
let start_offset = self.reader.offset();
let mut candidates = 0;
'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") {
self.reader.read_bytes(pos)?;
@@ -197,8 +210,13 @@ impl<'a> UntypedIter<'a> {
// new we don't bother trying to read it.
let tail = &self.reader.tail()?[2..];
let mut find_reader = Reader::new(tail);
// PrintCraft patch: bounded (see `MAX_EI_CANDIDATES`).
candidates += 1;
let mut steps = 0;
let heuristic = !self.lookahead && candidates <= MAX_EI_CANDIDATES;
while !find_reader.at_end() {
while heuristic && !find_reader.at_end() && steps < MAX_EI_LOOKAHEAD_STEPS {
steps += 1;
let remaining = find_reader.tail()?;
let next_ei = find_needle(remaining, b"EI");
let next_bi = find_needle(remaining, b"BI");
@@ -231,7 +249,7 @@ impl<'a> UntypedIter<'a> {
// stream and there should be at least one text-related
// operator that can be parsed correctly.
let mut iter = TypedIter::new(tail);
let mut iter = TypedIter::from_untyped(UntypedIter { lookahead: true, ..UntypedIter::new(tail) });
let mut found = false;
let mut counter = 0;
+9 -5
View File
@@ -12,11 +12,15 @@ pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
/// The release `printcraft-cli` that `cargo build --release -p printcraft-cli` produced: under
/// `CARGO_TARGET_DIR` when it is set (parallel agents use their own), else `target/`.
/// Cargo's target directory: `CARGO_TARGET_DIR` when it is set (parallel agents use their own),
/// else `target/`.
pub fn target_dir() -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d))
}
/// The release `printcraft-cli` that `cargo build --release -p printcraft-cli` produced.
pub fn release_cli() -> PathBuf {
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d));
target.join("release").join(format!("printcraft-cli{}", std::env::consts::EXE_SUFFIX))
target_dir().join("release").join(format!("printcraft-cli{}", std::env::consts::EXE_SUFFIX))
}
fn cargo() -> Command {
@@ -163,7 +167,7 @@ pub fn check(args: &[String]) -> anyhow::Result<()> {
bail!("corpus missing: run `cargo xtask corpus` first");
}
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
let report = root().join("target/check-pdfjs.json");
let report = target_dir().join("check-pdfjs.json");
let mut c = Command::new(release_cli());
c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report);
run(c, "printcraft-cli check corpus/pdfjs")?;