diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 3b2c906c5..0629ba609 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -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 = const { std::cell::Cell::new(0) }; + /// The objects being loaded on this thread, innermost last (see `Document::try_get`). + static LOADING: std::cell::RefCell> = 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>> = 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 { - LOAD_DEPTH.with(|d| { - if d.get() >= Self::MAX { + fn enter(num: u32) -> Option { + 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"); + } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 01444b9d4..74a4a3746 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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 = 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] diff --git a/vendor/README.md b/vendor/README.md index 82b0d7137..a8a848d39 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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`. diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 96a42eccc..4338837af 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -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 = 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; diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index e73eab58c..3f651ccef 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -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(); diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index e972a3d57..54188f206 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -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); diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 794504713..59163dd87 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -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>, + /// 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; diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 1d1d40f43..9a8ae223b 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -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")?;