imprimatur 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b065ca85ce09dca629c5a4ca92fbd0c361a8855b7226fce30e6a6e93f587cb
4
- data.tar.gz: 63bbf24b4c97feb96676db2c59a2d137220799a993ea51d707977d37d3e90cf2
3
+ metadata.gz: b79ecca5f76986eb4049090056dcee55fe7b8d2aafa1115de48ea2f45b0e12ef
4
+ data.tar.gz: 925d5c93f99da882db19b562c749ba41c1ae17a5fd01d17b66bd9be93a9dc15a
5
5
  SHA512:
6
- metadata.gz: 3f58a00ab92228d0cb3c1403c1111909ebda037e776e78b05a0d3830b92f46501a34a3d069303ad6092dfe0f30b5544b2638ce4b708631f60706ef04c21d09ae
7
- data.tar.gz: 32f985236d5804b10812fe87d5f2e74488085d35dd1a66d648474c47f07537e1ff62a7c04f8b06c51847ac0d3d557d35804b308f7972e2b2df24a020e562630c
6
+ metadata.gz: e28f3c9640d20df215ed94a94195fcea8a28336f1ddc1d5a923355e09fdc68142517b92147c3b76dc089b68294c8aa4b4edde1a832fc613fb44612d92e4773ba
7
+ data.tar.gz: c7f0d6f08c5f9349574e03c3ec025c82f737afdeb5e4e8c6994bf7afe0c5a7fa7091e418495691346f43b987a7abd7451d6eeca207b87dd911a523508039ac3a
data/CHANGELOG.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - Add native PDF metadata, bounded page reads with explicit failure states,
6
- and optional pure-Rust full-page PNG rendering.
7
- - Keep scanned and blank page structure available without a text layer.
8
- - Render PDFs through a bundled Rust worker with time, memory and output limits;
9
- cancellation kills and reaps it. macOS uses an RSS watchdog, not a hard cap.
5
+ - Isolate PDF metadata and text reads in the bundled resource-limited Rust
6
+ worker, as rendering already is; all three accept `memory_bytes:`.
7
+ - On Linux/FreeBSD, re-exec the worker under its address-space limit before
8
+ reading the PDF, including tiny metadata-only reads.
9
+ - Preserve completed pages on worker termination and recover healthy neighbors
10
+ after page-local stream limits without renewing the overall deadline.
11
+ - Allow a bounded exit grace when macOS cannot inspect an exiting worker;
12
+ persistent monitor failures still terminate and reap a live worker.
13
+
14
+ ## [0.6.1](https://github.com/seuros/imprimatur/compare/v0.6.0...v0.6.1) (2026-10-25)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **pdf:** isolate bounded reads and preserve page recovery ([fbfbbc5](https://github.com/seuros/imprimatur/commit/fbfbbc5e7e44768efae293b37be255b8b797f831))
10
20
 
11
21
  ## [0.6.0](https://github.com/seuros/imprimatur/compare/v0.5.0...v0.6.0) (2026-10-25)
12
22
 
data/Cargo.lock CHANGED
@@ -513,7 +513,7 @@ dependencies = [
513
513
 
514
514
  [[package]]
515
515
  name = "imprimatur"
516
- version = "0.6.0"
516
+ version = "0.6.1"
517
517
  dependencies = [
518
518
  "aes",
519
519
  "crc32fast",
@@ -526,6 +526,7 @@ dependencies = [
526
526
  "nix",
527
527
  "qrcode",
528
528
  "read-fonts 0.45.1",
529
+ "serde",
529
530
  "serde_json",
530
531
  "sha2",
531
532
  "tempfile",
@@ -535,7 +536,7 @@ dependencies = [
535
536
 
536
537
  [[package]]
537
538
  name = "imprimatur-cli"
538
- version = "0.6.0"
539
+ version = "0.6.1"
539
540
  dependencies = [
540
541
  "imprimatur",
541
542
  "regex",
@@ -547,7 +548,7 @@ dependencies = [
547
548
 
548
549
  [[package]]
549
550
  name = "imprimatur_native"
550
- version = "0.6.0"
551
+ version = "0.6.1"
551
552
  dependencies = [
552
553
  "imprimatur",
553
554
  "magnus",
data/Cargo.toml CHANGED
@@ -15,7 +15,7 @@ crc32fast = "1.5.2"
15
15
  datamatrix = "0.3.3"
16
16
  font-test-data = "0.10.0"
17
17
  hayro = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
18
- imprimatur = { path = "ext/imprimatur_native/core", version = "0.6.0" }
18
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.6.1" }
19
19
  libproc = "0.14.11"
20
20
  magnus = { version = "0.9.2", default-features = false }
21
21
  md-5 = "0.11.0"
@@ -27,6 +27,7 @@ read-fonts = "0.45.1"
27
27
  # rb-sys, features and all.
28
28
  rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api"] }
29
29
  regex = "1.13.1"
30
+ serde = { version = "1.0.229", features = ["derive"] }
30
31
  serde_json = "1.0.154"
31
32
  sha2 = "0.11.1"
32
33
  tempfile = "3"
data/README.md CHANGED
@@ -362,20 +362,31 @@ before allocation (`max_pixels:`, 4 million) and encoded output
362
362
  (`max_output_bytes:`, 8 MiB). Preflight rejects pages drawing more than 40 million
363
363
  source-image pixels. Renderer warnings fail instead of silently dropping content.
364
364
  The crate exposes `read::pdf::{info, pages, render_page}` (`render` feature) and
365
- `read::pdf::isolation::render_page_isolated` (`isolation` feature). The latter
366
- takes an absolute path to the trusted `imprimatur-render-worker` executable.
367
- The gem bundles that worker and always uses isolated rendering; it never falls
368
- back to an in-process render or searches PATH.
365
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`
366
+ (`isolation` feature). These take an absolute path to the trusted
367
+ `imprimatur-render-worker` executable. The gem bundles that worker and always
368
+ isolates all three PDF operations; it never falls back to in-process parsing
369
+ or searches PATH.
369
370
 
370
- All three accept the normal read limits. Rendering also accepts `memory_bytes:`
371
+ All three accept the normal read limits and `memory_bytes:`
371
372
  (1 GiB by default): Linux/FreeBSD enforce an address-space cap; macOS uses a
372
373
  sampled RSS watchdog, which can briefly overshoot. A worker is killed and reaped
373
- on timeout, cancellation or supervisor failure. Rendering has a mandatory
374
+ on timeout, cancellation or supervisor failure. Each operation has a mandatory
374
375
  60-second process ceiling even with `timeout: nil`; a shorter `timeout:` still
375
376
  applies. CPU time, output files and core dumps are also limited.
376
377
 
378
+ The page worker flushes completed records as it goes. An exhausted stream
379
+ budget triggers individual-page recovery inside that worker: each attempt has
380
+ its own `max_inflated` allowance, while the original overall deadline, process
381
+ memory limit and aggregate text cap remain in force. An oversized page is
382
+ `failed`, not blank, without discarding healthy neighbors. A worker terminated
383
+ after metadata was read preserves completed pages and explicitly marks the
384
+ unreached pages; an expired overall deadline marks them `skipped`. Cancellation
385
+ still raises rather than returning a partial successful call.
386
+
377
387
  This is resource containment, **not a filesystem/network security sandbox**.
378
- The raw crate `render_page` remains in-process and cannot interrupt Hayro.
388
+ The raw crate `info`, `pages` and `render_page` APIs remain in-process;
389
+ `render_page` cannot interrupt Hayro.
379
390
  `max_inflated` bounds our preflight, not Hayro's internal decoders.
380
391
 
381
392
  ```ruby
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = "Read PDF, XLSX, XLS, ODS and DOCX documents as Markdown or JSON, search them, extract their images."
5
5
  keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
6
6
  categories = ["command-line-utilities", "text-processing"]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = "XLSX, PDF and DOCX authoring, and PDF, XLSX, XLS, ODS and DOCX reading, with its own XML, zip and PDF code."
5
5
  keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
6
6
  categories = ["encoding", "text-processing"]
@@ -33,6 +33,7 @@ md-5.workspace = true
33
33
  miniz_oxide.workspace = true
34
34
  qrcode.workspace = true
35
35
  read-fonts = { workspace = true, optional = true }
36
+ serde = { workspace = true, optional = true }
36
37
  serde_json = { workspace = true, optional = true }
37
38
  sha2.workspace = true
38
39
  thiserror.workspace = true
@@ -51,7 +52,7 @@ archive = ["dep:zip"]
51
52
  fonts = ["dep:read-fonts"]
52
53
  # Pure-Rust full-page PDF rasterization; readers and writers remain independent.
53
54
  render = ["dep:hayro"]
54
- isolation = ["render", "dep:tempfile", "dep:nix", "dep:libproc"]
55
+ isolation = ["render", "json", "dep:serde", "dep:tempfile", "dep:nix", "dep:libproc"]
55
56
 
56
57
  [target.'cfg(unix)'.dependencies]
57
58
  nix = { workspace = true, optional = true }
@@ -11,10 +11,15 @@ states and UTF-8-safe text limits. `read::pdf::render_page`, under the optional
11
11
  `render` feature, rasterizes a full page through Hayro (pure Rust).
12
12
  That raw API is not preemptible. For untrusted documents, enable `isolation`,
13
13
  build the `imprimatur-render-worker` binary and pass its absolute path to
14
- `read::pdf::isolation::render_page_isolated`. Rust supervises finite wall/CPU,
14
+ `read::pdf::isolation::{info_isolated, pages_isolated, render_page_isolated}`.
15
+ Rust supervises finite wall/CPU,
15
16
  memory and file-output limits, killing and reaping failed/cancelled workers.
16
17
  Linux/FreeBSD enforce address-space limits; macOS samples RSS and can briefly
17
18
  overshoot. This is resource containment, not a filesystem/network sandbox.
19
+ Isolated page reads flush completed records, retain them on worker termination,
20
+ and recover healthy neighbors after page-local decompression failures. Recovery
21
+ attempts renew only the individual decompression allowance, never the overall
22
+ deadline, process-memory limit or aggregate text cap.
18
23
 
19
24
  ```rust
20
25
  use imprimatur::xlsx::{Cell, Color, DateTime, Format, Workbook};
@@ -11,6 +11,7 @@ use crate::{Error, Result};
11
11
 
12
12
  /// Metadata read without interpreting page content.
13
13
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
14
15
  pub struct Info {
15
16
  pub page_count: u32,
16
17
  /// Opened with the empty password: permissions, not a locked document.
@@ -40,7 +41,7 @@ impl Default for ReadOptions {
40
41
  }
41
42
 
42
43
  impl ReadOptions {
43
- const fn validate(&self) -> Result<()> {
44
+ pub(super) const fn validate(&self) -> Result<()> {
44
45
  if self.first_page == 0 || self.last_page < self.first_page {
45
46
  return Err(Error::InvalidPdfOption("pages must be an ascending one-based range"));
46
47
  }
@@ -53,6 +54,7 @@ impl ReadOptions {
53
54
 
54
55
  /// What is known about a page; failure is never reported as empty.
55
56
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
56
58
  pub enum Status {
57
59
  Text,
58
60
  Empty,
@@ -73,6 +75,7 @@ impl Status {
73
75
  }
74
76
 
75
77
  #[derive(Debug)]
78
+ #[cfg_attr(feature = "isolation", derive(serde::Serialize, serde::Deserialize))]
76
79
  pub struct PageResult {
77
80
  pub number: u32,
78
81
  pub status: Status,
@@ -106,6 +109,24 @@ pub fn info(data: &[u8], limits: &Limits) -> Result<Info> {
106
109
  /// # Errors
107
110
  /// As [`info`], and invalid options.
108
111
  pub fn pages(data: &[u8], options: &ReadOptions, limits: &Limits) -> Result<Pages> {
112
+ let mut pages = Vec::new();
113
+ let info = visit_pages(data, options, limits, false, |page| {
114
+ pages.push(page);
115
+ Ok(())
116
+ })?;
117
+ Ok(Pages { info, pages })
118
+ }
119
+
120
+ /// The worker flushes each completed page. After an exhausted stream budget,
121
+ /// single-page attempts get fresh decompression budgets, as individual reads
122
+ /// would; the supervisor still enforces one wall-clock and process limit.
123
+ pub(super) fn visit_pages(
124
+ data: &[u8],
125
+ options: &ReadOptions,
126
+ limits: &Limits,
127
+ recover: bool,
128
+ mut emit: impl FnMut(PageResult) -> Result<()>,
129
+ ) -> Result<Info> {
109
130
  options.validate()?;
110
131
  let budget = Budget::new(limits, data.len())?;
111
132
  let document = open(data, &budget)?;
@@ -114,52 +135,89 @@ pub fn pages(data: &[u8], options: &ReadOptions, limits: &Limits) -> Result<Page
114
135
  let mut fonts = Fonts::new();
115
136
  let mut text = PageText { strict: true, ..PageText::default() };
116
137
  let mut remaining = options.max_output_bytes;
117
- let mut results = Vec::with_capacity(selected.pages.len());
138
+ let mut individual = false;
118
139
  for (number, page) in selected.pages {
119
140
  budget.check()?;
120
141
  if remaining == 0 {
121
- results.push(unread(
122
- number,
123
- Status::Skipped,
124
- "the text output limit was reached".into(),
125
- ));
142
+ emit(unread(number, Status::Skipped, "the text output limit was reached".into()))?;
126
143
  continue;
127
144
  }
128
145
  text.max_bytes = Some(options.max_page_bytes.min(remaining));
129
- let read = page.and_then(|page| page_text(&document, &page, &mut fonts, &mut text));
130
- match read {
131
- Ok(()) => {
132
- let lines = layout::lines(&text);
133
- let mut output = String::new();
134
- for line in &lines.lines {
135
- if !output.is_empty() {
136
- output.push('\n');
137
- }
138
- output.push_str(lines.text(line));
146
+ let read = if individual {
147
+ single_page(data, number, options, remaining, limits)
148
+ } else {
149
+ page.and_then(|page| page_text(&document, &page, &mut fonts, &mut text))
150
+ .map(|()| text_result(number, &text, text.max_bytes.unwrap_or(remaining)))
151
+ };
152
+ let read = match read {
153
+ Err(error @ Error::LimitExceeded(_)) if recover => {
154
+ // Never renew an expired time budget or swallow cancellation.
155
+ budget.check()?;
156
+ if individual {
157
+ Err(error)
158
+ } else {
159
+ individual = true;
160
+ single_page(data, number, options, remaining, limits)
139
161
  }
140
- let limit = options.max_page_bytes.min(remaining);
141
- let truncated = text.truncated || output.len() > limit;
142
- truncate(&mut output, limit);
143
- remaining -= output.len();
144
- results.push(PageResult {
145
- number,
146
- status: if output.trim().is_empty() { Status::Empty } else { Status::Text },
147
- text: output,
148
- images: Some(u32::try_from(text.images).unwrap_or(u32::MAX)),
149
- truncated,
150
- reason: truncated.then(|| "the page text limit was reached".into()),
151
- });
162
+ }
163
+ other => other,
164
+ };
165
+ let result = match read {
166
+ Ok(page) => page,
167
+ Err(error @ Error::LimitExceeded(_)) if recover => {
168
+ budget.check()?;
169
+ unread(number, Status::Failed, error.to_string())
152
170
  }
153
171
  Err(error) if xref::is_limit(&error) || matches!(error, Error::PasswordRequired) => {
154
172
  return Err(error);
155
173
  }
156
- Err(error) => results.push(unread(number, Status::Failed, error.to_string())),
174
+ Err(error) => unread(number, Status::Failed, error.to_string()),
175
+ };
176
+ remaining -= result.text.len();
177
+ emit(result)?;
178
+ }
179
+ Ok(Info { page_count: selected.count, encrypted: document.encrypted() })
180
+ }
181
+
182
+ fn single_page(
183
+ data: &[u8],
184
+ number: u32,
185
+ options: &ReadOptions,
186
+ remaining: usize,
187
+ limits: &Limits,
188
+ ) -> Result<PageResult> {
189
+ let options = ReadOptions {
190
+ first_page: number,
191
+ last_page: number,
192
+ max_pages: 1,
193
+ max_output_bytes: remaining,
194
+ ..options.clone()
195
+ };
196
+ pages(data, &options, limits)?
197
+ .pages
198
+ .pop()
199
+ .ok_or_else(|| Error::malformed("PDF", "the selected page disappeared"))
200
+ }
201
+
202
+ fn text_result(number: u32, text: &PageText, limit: usize) -> PageResult {
203
+ let lines = layout::lines(text);
204
+ let mut output = String::new();
205
+ for line in &lines.lines {
206
+ if !output.is_empty() {
207
+ output.push('\n');
157
208
  }
209
+ output.push_str(lines.text(line));
210
+ }
211
+ let truncated = text.truncated || output.len() > limit;
212
+ truncate(&mut output, limit);
213
+ PageResult {
214
+ number,
215
+ status: if output.trim().is_empty() { Status::Empty } else { Status::Text },
216
+ text: output,
217
+ images: Some(u32::try_from(text.images).unwrap_or(u32::MAX)),
218
+ truncated,
219
+ reason: truncated.then(|| "the page text limit was reached".into()),
158
220
  }
159
- Ok(Pages {
160
- info: Info { page_count: selected.count, encrypted: document.encrypted() },
161
- pages: results,
162
- })
163
221
  }
164
222
 
165
223
  pub(super) fn open<'a>(data: &'a [u8], budget: &'a Budget<'a>) -> Result<Document<'a>> {
@@ -170,7 +228,8 @@ pub(super) fn open<'a>(data: &'a [u8], budget: &'a Budget<'a>) -> Result<Documen
170
228
  Document::open(data, budget).map(Document::strict)
171
229
  }
172
230
 
173
- const fn unread(number: u32, status: Status, reason: String) -> PageResult {
231
+ pub(super) fn unread(number: u32, status: Status, mut reason: String) -> PageResult {
232
+ truncate(&mut reason, 4096);
174
233
  PageResult {
175
234
  number,
176
235
  status,
@@ -1,105 +1,190 @@
1
- //! A versioned, bounded reply and numeric-only worker arguments.
1
+ //! Versioned replies and numeric-only arguments for the trusted PDF worker.
2
2
 
3
3
  use std::ffi::OsString;
4
4
  use std::io::Write;
5
+ use std::time::Duration;
5
6
 
6
7
  use super::ProcessLimits;
7
8
  use crate::read::Limits;
8
- use crate::read::pdf::RenderOptions;
9
+ use crate::read::pdf::{ReadOptions, RenderOptions};
9
10
  use crate::{Error, Result};
10
11
 
11
- pub(super) const HEADER: &[u8; 6] = b"IMPR\x01\0";
12
+ pub(super) const HEADER: &[u8; 6] = b"IMPR\x02\0";
12
13
  pub(super) const MAX_ERROR_BYTES: usize = 4096;
13
14
 
15
+ pub(super) enum Operation {
16
+ Info,
17
+ Pages(ReadOptions),
18
+ Render(u32, RenderOptions),
19
+ }
20
+
21
+ impl Operation {
22
+ pub fn maximum(&self) -> Result<usize> {
23
+ let invalid = || Error::InvalidPdfOption("worker output limit is too large");
24
+ let maximum = match self {
25
+ Self::Info => MAX_ERROR_BYTES,
26
+ // JSON can escape each text byte as six ASCII bytes. Reasons and
27
+ // record metadata are separately bounded for every selected page.
28
+ Self::Pages(options) => options
29
+ .max_output_bytes
30
+ .checked_mul(6)
31
+ .and_then(|text| {
32
+ options.max_pages.checked_mul(MAX_ERROR_BYTES * 6 + 512)?.checked_add(text)
33
+ })
34
+ .and_then(|bytes| bytes.checked_add(MAX_ERROR_BYTES * 6 + 512))
35
+ .ok_or_else(invalid)?,
36
+ Self::Render(_, options) => options.max_output_bytes,
37
+ };
38
+ maximum.max(MAX_ERROR_BYTES).checked_add(HEADER.len()).ok_or_else(invalid)
39
+ }
40
+ }
41
+
14
42
  pub(super) struct Request {
15
- pub number: u32,
16
- pub options: RenderOptions,
43
+ pub restricted: bool,
44
+ pub operation: Operation,
17
45
  pub limits: Limits,
18
46
  pub memory: u64,
19
47
  pub cpu_seconds: u64,
20
48
  }
21
49
 
22
50
  impl Request {
23
- pub fn new(
24
- number: u32,
25
- options: &RenderOptions,
26
- limits: &Limits,
27
- process: &ProcessLimits,
28
- ) -> Result<Self> {
29
- options
30
- .max_output_bytes
31
- .checked_add(HEADER.len())
32
- .ok_or(Error::InvalidPdfOption("output limit is too large"))?;
51
+ pub fn new(operation: Operation, limits: &Limits, process: &ProcessLimits) -> Result<Self> {
52
+ operation.maximum()?;
53
+ match &operation {
54
+ Operation::Pages(options) => options.validate()?,
55
+ Operation::Render(0, _) => {
56
+ return Err(Error::InvalidPdfOption("page number must be positive"));
57
+ }
58
+ _ => {}
59
+ }
33
60
  if process.memory_bytes == 0 || process.memory_bytes >= i64::MAX as u64 {
34
61
  return Err(Error::InvalidPdfOption("memory limit must be positive and finite"));
35
62
  }
36
- let cpu_seconds = process
37
- .wall_time
63
+ let wall = limits.timeout.unwrap_or(process.wall_time).min(process.wall_time);
64
+ let cpu_seconds = wall
38
65
  .as_secs()
39
- .checked_add(u64::from(process.wall_time.subsec_nanos() != 0))
66
+ .checked_add(u64::from(wall.subsec_nanos() != 0))
40
67
  .filter(|&seconds| seconds != 0 && seconds < i64::MAX as u64)
41
68
  .ok_or(Error::InvalidPdfOption("worker wall time must be positive and finite"))?;
69
+ u64::try_from(wall.as_nanos())
70
+ .map_err(|_| Error::InvalidPdfOption("worker wall time is too large"))?;
42
71
  Ok(Self {
43
- number,
44
- options: options.clone(),
45
- limits: limits.clone(),
72
+ restricted: false,
73
+ operation,
74
+ limits: limits.clone().timeout(Some(wall)),
46
75
  memory: process.memory_bytes,
47
76
  cpu_seconds,
48
77
  })
49
78
  }
50
79
 
51
80
  pub fn arguments(&self) -> Vec<OsString> {
52
- [
53
- u64::from(self.number),
54
- u64::from(self.options.max_side),
55
- self.options.max_pixels,
56
- self.options.max_output_bytes as u64,
57
- u64::from(self.options.grayscale),
81
+ let timeout = self.limits.timeout.unwrap_or_default();
82
+ let mut values = vec![
83
+ u64::from(self.restricted),
58
84
  self.limits.max_input as u64,
59
85
  self.limits.max_inflated,
60
86
  self.limits.max_depth as u64,
61
87
  self.memory,
62
88
  self.cpu_seconds,
63
- ]
64
- .map(|number| OsString::from(number.to_string()))
65
- .to_vec()
89
+ u64::try_from(timeout.as_nanos()).unwrap_or(u64::MAX),
90
+ ];
91
+ match &self.operation {
92
+ Operation::Info => values.push(0),
93
+ Operation::Pages(options) => values.extend([
94
+ 1,
95
+ u64::from(options.first_page),
96
+ u64::from(options.last_page),
97
+ options.max_pages as u64,
98
+ options.max_page_bytes as u64,
99
+ options.max_output_bytes as u64,
100
+ ]),
101
+ Operation::Render(number, options) => values.extend([
102
+ 2,
103
+ u64::from(*number),
104
+ u64::from(options.max_side),
105
+ options.max_pixels,
106
+ options.max_output_bytes as u64,
107
+ u64::from(options.grayscale),
108
+ ]),
109
+ }
110
+ values.into_iter().map(|number| OsString::from(number.to_string())).collect()
66
111
  }
67
112
 
68
113
  pub fn parse() -> Result<Self> {
69
- let invalid = || Error::InvalidPdfOption("invalid render worker arguments");
114
+ let invalid = || Error::InvalidPdfOption("invalid PDF worker arguments");
70
115
  let values: Vec<u64> = std::env::args()
71
116
  .skip(1)
72
117
  .map(|value| value.parse().map_err(|_| invalid()))
73
118
  .collect::<Result<_>>()?;
74
- let [number, side, pixels, output, gray, input, inflated, depth, memory, cpu] =
119
+ let [restricted, input, inflated, depth, memory, cpu, timeout, rest @ ..] =
75
120
  values.as_slice()
76
121
  else {
77
122
  return Err(invalid());
78
123
  };
79
- if *gray > 1
80
- || *memory == 0
81
- || *memory >= i64::MAX as u64
82
- || *cpu == 0
83
- || *cpu >= i64::MAX as u64
84
- {
124
+ if *restricted > 1 {
85
125
  return Err(invalid());
86
126
  }
87
- Ok(Self {
88
- number: u32::try_from(*number).map_err(|_| invalid())?,
89
- options: RenderOptions {
90
- max_side: u16::try_from(*side).map_err(|_| invalid())?,
91
- max_pixels: *pixels,
92
- max_output_bytes: usize::try_from(*output).map_err(|_| invalid())?,
93
- grayscale: *gray == 1,
94
- },
95
- limits: Limits::default()
96
- .max_input(usize::try_from(*input).map_err(|_| invalid())?)
97
- .max_inflated(*inflated)
98
- .max_depth(usize::try_from(*depth).map_err(|_| invalid())?)
99
- .timeout(None),
100
- memory: *memory,
101
- cpu_seconds: *cpu,
102
- })
127
+ let length = |value| usize::try_from(value).map_err(|_| invalid());
128
+ let operation = match rest {
129
+ [0] => Operation::Info,
130
+ [1, first, last, count, page, output] => Operation::Pages(ReadOptions {
131
+ first_page: u32::try_from(*first).map_err(|_| invalid())?,
132
+ last_page: u32::try_from(*last).map_err(|_| invalid())?,
133
+ max_pages: length(*count)?,
134
+ max_page_bytes: length(*page)?,
135
+ max_output_bytes: length(*output)?,
136
+ }),
137
+ [2, number, side, pixels, output, gray] if *gray <= 1 => Operation::Render(
138
+ u32::try_from(*number).map_err(|_| invalid())?,
139
+ RenderOptions {
140
+ max_side: u16::try_from(*side).map_err(|_| invalid())?,
141
+ max_pixels: *pixels,
142
+ max_output_bytes: length(*output)?,
143
+ grayscale: *gray == 1,
144
+ },
145
+ ),
146
+ _ => return Err(invalid()),
147
+ };
148
+ let limits = Limits::default()
149
+ .max_input(length(*input)?)
150
+ .max_inflated(*inflated)
151
+ .max_depth(length(*depth)?)
152
+ .timeout(Some(Duration::from_nanos(*timeout)));
153
+ let process =
154
+ ProcessLimits { memory_bytes: *memory, wall_time: Duration::from_nanos(*timeout) };
155
+ let mut request = Self::new(operation, &limits, &process)?;
156
+ request.restricted = *restricted == 1;
157
+ if request.cpu_seconds != *cpu {
158
+ return Err(invalid());
159
+ }
160
+ Ok(request)
161
+ }
162
+ }
163
+
164
+ pub(super) fn failure(error: &Error) -> (u8, String) {
165
+ let kind = match error {
166
+ Error::PasswordRequired => 1,
167
+ Error::UnsupportedDocument(_) => 2,
168
+ Error::LimitExceeded(_) | Error::Cancelled => 3,
169
+ Error::InvalidPdfOption(_) => 4,
170
+ _ => 5,
171
+ };
172
+ let mut message = error.to_string();
173
+ let mut end = message.len().min(MAX_ERROR_BYTES);
174
+ while !message.is_char_boundary(end) {
175
+ end -= 1;
176
+ }
177
+ message.truncate(end);
178
+ (kind, message)
179
+ }
180
+
181
+ pub(super) fn error(kind: u8, message: String) -> Error {
182
+ match kind {
183
+ 1 => Error::PasswordRequired,
184
+ 2 => Error::UnsupportedDocument(message),
185
+ 3 => Error::LimitExceeded("PDF worker limit"),
186
+ 4 => Error::InvalidPdfOption("the PDF worker rejected the options"),
187
+ _ => Error::malformed("PDF worker", message),
103
188
  }
104
189
  }
105
190
 
@@ -111,20 +196,10 @@ pub(super) fn write_reply(mut out: impl Write, result: Result<Vec<u8>>) -> Resul
111
196
  out.write_all(&data)?;
112
197
  }
113
198
  Err(error) => {
114
- header[5] = match &error {
115
- Error::PasswordRequired => 1,
116
- Error::UnsupportedDocument(_) => 2,
117
- Error::LimitExceeded(_) | Error::Cancelled => 3,
118
- Error::InvalidPdfOption(_) => 4,
119
- _ => 5,
120
- };
121
- let message = error.to_string();
122
- let mut end = message.len().min(MAX_ERROR_BYTES);
123
- while !message.is_char_boundary(end) {
124
- end -= 1;
125
- }
199
+ let (kind, message) = failure(&error);
200
+ header[5] = kind;
126
201
  out.write_all(&header)?;
127
- out.write_all(&message.as_bytes()[..end])?;
202
+ out.write_all(message.as_bytes())?;
128
203
  }
129
204
  }
130
205
  out.flush()?;
@@ -133,23 +208,17 @@ pub(super) fn write_reply(mut out: impl Write, result: Result<Vec<u8>>) -> Resul
133
208
 
134
209
  pub(super) fn decode(mut data: Vec<u8>, maximum: usize) -> Result<Vec<u8>> {
135
210
  if data.len() < HEADER.len() || data[..5] != HEADER[..5] {
136
- return Err(Error::malformed("render worker", "missing or incompatible response"));
211
+ return Err(Error::malformed("PDF worker", "missing or incompatible response"));
137
212
  }
138
213
  let status = data[5];
139
214
  data.drain(..HEADER.len());
140
215
  if status == 0 {
141
216
  return if data.len() > maximum {
142
- Err(Error::LimitExceeded("rendered bytes"))
217
+ Err(Error::LimitExceeded("PDF worker output"))
143
218
  } else {
144
219
  Ok(data)
145
220
  };
146
221
  }
147
222
  let message = String::from_utf8_lossy(&data[..data.len().min(MAX_ERROR_BYTES)]).into_owned();
148
- Err(match status {
149
- 1 => Error::PasswordRequired,
150
- 2 => Error::UnsupportedDocument(message),
151
- 3 => Error::LimitExceeded("render worker limit"),
152
- 4 => Error::InvalidPdfOption("the render worker rejected the rendering options"),
153
- _ => Error::malformed("render worker", message),
154
- })
223
+ Err(error(status, message))
155
224
  }
@@ -0,0 +1,113 @@
1
+ //! Flushed page records survive a worker timeout or resource termination.
2
+
3
+ use std::io::Write;
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ use super::{Output, protocol};
8
+ use crate::read::pdf::{Info, PageResult, Pages, ReadOptions, Status, bounded};
9
+ use crate::{Error, Result};
10
+
11
+ #[derive(Serialize, Deserialize)]
12
+ pub(super) enum Frame {
13
+ Info(Info),
14
+ Page(PageResult),
15
+ End,
16
+ Error { kind: u8, message: String },
17
+ }
18
+
19
+ pub(super) fn write(out: &mut impl Write, frame: &Frame) -> Result<()> {
20
+ serde_json::to_writer(&mut *out, frame)
21
+ .map_err(|error| Error::malformed("PDF worker", error.to_string()))?;
22
+ out.write_all(b"\n")?;
23
+ out.flush()?;
24
+ Ok(())
25
+ }
26
+
27
+ pub(super) fn decode(output: Output, options: &ReadOptions) -> Result<Pages> {
28
+ let mut failure = output.failure;
29
+ if output.data.len() < protocol::HEADER.len() {
30
+ return Err(failure.unwrap_or_else(|| malformed("missing response")));
31
+ }
32
+ let bytes =
33
+ protocol::decode(output.data, protocol::Operation::Pages(options.clone()).maximum()?)?;
34
+ let mut info = None;
35
+ let mut pages = Vec::new();
36
+ let mut text_bytes = 0_usize;
37
+ let mut ended = false;
38
+ for line in bytes.split_inclusive(|&byte| byte == b'\n') {
39
+ if ended {
40
+ return Err(malformed("records after the end of the response"));
41
+ }
42
+ if !line.ends_with(b"\n") && failure.is_some() {
43
+ break; // A killed worker may leave one incomplete final record.
44
+ }
45
+ let frame: Frame =
46
+ serde_json::from_slice(line).map_err(|error| malformed(&error.to_string()))?;
47
+ match frame {
48
+ Frame::Info(value) if info.is_none() && pages.is_empty() => info = Some(value),
49
+ Frame::Page(page) => {
50
+ let metadata = info.ok_or_else(|| malformed("page before metadata"))?;
51
+ validate(&page, pages.len(), metadata, options)?;
52
+ text_bytes = text_bytes
53
+ .checked_add(page.text.len())
54
+ .filter(|&bytes| bytes <= options.max_output_bytes)
55
+ .ok_or_else(|| malformed("text exceeds the requested limit"))?;
56
+ pages.push(page);
57
+ }
58
+ Frame::End => ended = true,
59
+ Frame::Error { kind, message } => {
60
+ failure = Some(protocol::error(kind, message));
61
+ ended = true;
62
+ }
63
+ Frame::Info(_) => return Err(malformed("duplicate metadata")),
64
+ }
65
+ }
66
+ let info =
67
+ info.ok_or_else(|| failure.take().unwrap_or_else(|| malformed("missing metadata")))?;
68
+ let count = selected(info, options);
69
+ if pages.len() == count && ended {
70
+ return Ok(Pages { info, pages });
71
+ }
72
+ let error = failure.ok_or_else(|| malformed("incomplete response"))?;
73
+ let reason = error.to_string();
74
+ let timed_out = matches!(
75
+ error,
76
+ Error::LimitExceeded("time" | "render worker wall time" | "PDF worker limit")
77
+ );
78
+ let first_missing = pages.len();
79
+ for index in first_missing..count {
80
+ let number =
81
+ options.first_page + u32::try_from(index).map_err(|_| malformed("page index"))?;
82
+ let status =
83
+ if timed_out || index > first_missing { Status::Skipped } else { Status::Failed };
84
+ pages.push(bounded::unread(number, status, reason.clone()));
85
+ }
86
+ Ok(Pages { info, pages })
87
+ }
88
+
89
+ fn selected(info: Info, options: &ReadOptions) -> usize {
90
+ let count = options
91
+ .last_page
92
+ .min(info.page_count)
93
+ .checked_sub(options.first_page)
94
+ .map_or(0, |difference| u64::from(difference) + 1);
95
+ usize::try_from(count).unwrap_or(usize::MAX).min(options.max_pages)
96
+ }
97
+
98
+ fn validate(page: &PageResult, index: usize, info: Info, options: &ReadOptions) -> Result<()> {
99
+ let expected = u64::from(options.first_page) + index as u64;
100
+ if index >= selected(info, options)
101
+ || u64::from(page.number) != expected
102
+ || page.text.len() > options.max_page_bytes
103
+ || page.reason.as_ref().is_some_and(|reason| reason.len() > protocol::MAX_ERROR_BYTES)
104
+ || (matches!(page.status, Status::Failed | Status::Skipped) && !page.text.is_empty())
105
+ {
106
+ return Err(malformed("invalid page record"));
107
+ }
108
+ Ok(())
109
+ }
110
+
111
+ fn malformed(reason: &str) -> Error {
112
+ Error::malformed("PDF worker", reason)
113
+ }
@@ -28,6 +28,33 @@ pub(super) fn apply_limits(memory: u64, cpu: u64, output: u64) -> Result<()> {
28
28
  Ok(())
29
29
  }
30
30
 
31
+ /// Replace the bootstrap after setting address-space limits, so the loader
32
+ /// and parser cannot reuse memory mapped before the cap was installed.
33
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
34
+ pub(super) fn reexec(arguments: &[std::ffi::OsString]) -> Result<()> {
35
+ use std::ffi::CString;
36
+ use std::os::unix::ffi::OsStrExt;
37
+
38
+ let string = |value: &[u8]| {
39
+ CString::new(value)
40
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))
41
+ };
42
+ let executable = string(std::env::current_exe()?.as_os_str().as_bytes())?;
43
+ let mut args = vec![executable.clone(), string(b"1")?];
44
+ for argument in arguments.iter().skip(1) {
45
+ args.push(string(argument.as_os_str().as_bytes())?);
46
+ }
47
+ match nix::unistd::execv(&executable, &args) {
48
+ Ok(never) => match never {},
49
+ // A tiny address-space cap can prevent exec from allocating its
50
+ // argument/environment pages and surface as E2BIG rather than ENOMEM.
51
+ Err(Errno::ENOMEM | Errno::E2BIG) => {
52
+ Err(Error::LimitExceeded("PDF worker startup resources"))
53
+ }
54
+ Err(error) => Err(std::io::Error::from(error).into()),
55
+ }
56
+ }
57
+
31
58
  fn lower(resource: Resource, value: u64) -> Result<()> {
32
59
  let (soft, hard) = getrlimit(resource).map_err(std::io::Error::from)?;
33
60
  let value = bounded_limit(value, soft, hard)?;
@@ -1,43 +1,74 @@
1
- //! The companion worker sets limits before touching the document.
1
+ //! Apply resource limits before parsing any document bytes.
2
2
 
3
- use std::io::Read;
3
+ use std::io::{Read, Write};
4
4
  use std::process::ExitCode;
5
5
 
6
- use super::{protocol, system};
7
- use crate::read::pdf::render_page;
6
+ use super::protocol::{Operation, Request};
7
+ use super::{protocol, stream, system};
8
+ use crate::read::pdf::{self, bounded};
8
9
  use crate::{Error, Result};
9
10
 
10
- /// Internal entry point for the bundled worker executable.
11
+ /// Internal entry point for the bundled PDF worker executable.
11
12
  #[doc(hidden)]
12
13
  #[must_use]
13
14
  pub fn worker_main() -> ExitCode {
14
- match protocol::write_reply(std::io::stdout().lock(), run()) {
15
+ let mut out = std::io::stdout().lock();
16
+ let result = Request::parse().and_then(|request| run(&request, &mut out));
17
+ match result.or_else(|error| protocol::write_reply(&mut out, Err(error))) {
15
18
  Ok(()) => ExitCode::SUCCESS,
16
19
  Err(error) => {
17
- eprintln!("imprimatur render worker: {error}");
20
+ eprintln!("imprimatur PDF worker: {error}");
18
21
  ExitCode::FAILURE
19
22
  }
20
23
  }
21
24
  }
22
25
 
23
- fn run() -> Result<Vec<u8>> {
24
- let request = protocol::Request::parse()?;
25
- let output = request
26
- .options
27
- .max_output_bytes
28
- .max(protocol::MAX_ERROR_BYTES)
29
- .checked_add(protocol::HEADER.len())
30
- .and_then(|value| u64::try_from(value).ok())
31
- .ok_or(Error::InvalidPdfOption("output limit is too large"))?;
26
+ fn run(request: &Request, out: &mut impl Write) -> Result<()> {
27
+ let output = u64::try_from(request.operation.maximum()?)
28
+ .map_err(|_| Error::InvalidPdfOption("worker output limit is too large"))?;
32
29
  system::apply_limits(request.memory, request.cpu_seconds, output)?;
33
- let mut bytes = Vec::new();
30
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
31
+ if !request.restricted {
32
+ system::reexec(&request.arguments())?;
33
+ }
34
34
  let maximum = u64::try_from(request.limits.max_input)
35
35
  .ok()
36
36
  .and_then(|value| value.checked_add(1))
37
37
  .ok_or(Error::InvalidPdfOption("input limit is too large"))?;
38
+ let mut bytes = Vec::new();
38
39
  std::io::stdin().lock().take(maximum).read_to_end(&mut bytes)?;
39
40
  if bytes.len() > request.limits.max_input {
40
41
  return Err(Error::LimitExceeded("input size"));
41
42
  }
42
- render_page(&bytes, request.number, &request.options, &request.limits).map(|image| image.data)
43
+ match &request.operation {
44
+ Operation::Info => protocol::write_reply(
45
+ out,
46
+ pdf::info(&bytes, &request.limits).and_then(|info| {
47
+ serde_json::to_vec(&info)
48
+ .map_err(|error| Error::malformed("PDF worker", error.to_string()))
49
+ }),
50
+ ),
51
+ Operation::Render(number, options) => protocol::write_reply(
52
+ out,
53
+ pdf::render_page(&bytes, *number, options, &request.limits).map(|image| image.data),
54
+ ),
55
+ Operation::Pages(options) => {
56
+ out.write_all(protocol::HEADER)?;
57
+ let result = pdf::info(&bytes, &request.limits).and_then(|info| {
58
+ stream::write(out, &stream::Frame::Info(info))?;
59
+ bounded::visit_pages(&bytes, options, &request.limits, true, |page| {
60
+ stream::write(out, &stream::Frame::Page(page))
61
+ })?;
62
+ Ok(())
63
+ });
64
+ let frame = match result {
65
+ Ok(()) => stream::Frame::End,
66
+ Err(error) => {
67
+ let (kind, message) = protocol::failure(&error);
68
+ stream::Frame::Error { kind, message }
69
+ }
70
+ };
71
+ stream::write(out, &frame)
72
+ }
73
+ }
43
74
  }
@@ -1,7 +1,8 @@
1
- //! Render in a separate, resource-limited process. No untrusted PDF parsing
1
+ //! Read and render in a resource-limited process. No untrusted PDF parsing
2
2
  //! occurs in the supervisor.
3
3
 
4
4
  mod protocol;
5
+ mod stream;
5
6
  mod system;
6
7
  #[cfg(test)]
7
8
  mod tests;
@@ -14,11 +15,47 @@ use std::path::Path;
14
15
  use std::process::{Child, Command, ExitStatus, Stdio};
15
16
  use std::time::{Duration, Instant};
16
17
 
17
- use super::RenderOptions;
18
+ use super::{Info, Pages, ReadOptions, RenderOptions};
18
19
  use crate::read::{Budget, Image, Limits};
19
20
  use crate::{Error, Result};
20
21
  pub use worker::worker_main;
21
22
 
23
+ /// Inspects a PDF in the trusted, resource-limited worker.
24
+ ///
25
+ /// # Errors
26
+ /// Invalid options, worker errors, or exceeded input, memory or time limits.
27
+ pub fn info_isolated(
28
+ worker: &Path,
29
+ data: &[u8],
30
+ limits: &Limits,
31
+ process: &ProcessLimits,
32
+ ) -> Result<Info> {
33
+ let output = invoke(worker, data, protocol::Operation::Info, limits, process)?;
34
+ let bytes = output.complete(protocol::MAX_ERROR_BYTES)?;
35
+ serde_json::from_slice(&bytes)
36
+ .map_err(|error| Error::malformed("PDF worker", error.to_string()))
37
+ }
38
+
39
+ /// Reads PDF pages in the worker, retaining completed records on resource failure.
40
+ ///
41
+ /// Page-local stream limits trigger bounded individual recovery.
42
+ /// All attempts share one external wall-clock and process-memory limit.
43
+ ///
44
+ /// # Errors
45
+ /// As [`info_isolated`], and cancellation or an invalid worker reply.
46
+ /// A failure before page metadata is available remains a document error.
47
+ pub fn pages_isolated(
48
+ worker: &Path,
49
+ data: &[u8],
50
+ options: &ReadOptions,
51
+ limits: &Limits,
52
+ process: &ProcessLimits,
53
+ ) -> Result<Pages> {
54
+ let output =
55
+ invoke(worker, data, protocol::Operation::Pages(options.clone()), limits, process)?;
56
+ stream::decode(output, options)
57
+ }
58
+
22
59
  /// Mandatory process limits, in addition to the reader/render limits.
23
60
  #[derive(Debug, Clone)]
24
61
  pub struct ProcessLimits {
@@ -51,6 +88,44 @@ pub fn render_page_isolated(
51
88
  limits: &Limits,
52
89
  process: &ProcessLimits,
53
90
  ) -> Result<Image> {
91
+ let output = invoke(
92
+ worker,
93
+ data,
94
+ protocol::Operation::Render(number, options.clone()),
95
+ limits,
96
+ process,
97
+ )?;
98
+ let data = output.complete(options.max_output_bytes)?;
99
+ // The trusted worker writes only PNG; reject malformed/empty replies too.
100
+ if !data.starts_with(b"\x89PNG\r\n\x1a\n") || data.len() < 26 {
101
+ return Err(Error::malformed("PDF worker", "invalid PNG response"));
102
+ }
103
+ let dimension = |at| u32::from_be_bytes(data[at..at + 4].try_into().unwrap_or([0; 4]));
104
+ let (width, height) = (dimension(16), dimension(20));
105
+ options.image(data, number, (width, height))
106
+ }
107
+
108
+ struct Output {
109
+ data: Vec<u8>,
110
+ failure: Option<Error>,
111
+ }
112
+
113
+ impl Output {
114
+ fn complete(self, maximum: usize) -> Result<Vec<u8>> {
115
+ if let Some(error) = self.failure {
116
+ return Err(error);
117
+ }
118
+ protocol::decode(self.data, maximum)
119
+ }
120
+ }
121
+
122
+ fn invoke(
123
+ worker: &Path,
124
+ data: &[u8],
125
+ operation: protocol::Operation,
126
+ limits: &Limits,
127
+ process: &ProcessLimits,
128
+ ) -> Result<Output> {
54
129
  let budget = Budget::new(limits, data.len())?;
55
130
  budget.check()?;
56
131
  let deadline = Instant::now()
@@ -59,10 +134,12 @@ pub fn render_page_isolated(
59
134
  if !worker.is_absolute() || !worker.is_file() {
60
135
  return Err(Error::InvalidPdfOption("an absolute path to the render worker is required"));
61
136
  }
62
- if process.memory_bytes == 0 || process.wall_time.is_zero() || number == 0 {
63
- return Err(Error::InvalidPdfOption("process limits and page number must be positive"));
137
+ if process.memory_bytes == 0 || process.wall_time.is_zero() {
138
+ return Err(Error::InvalidPdfOption("process limits must be positive"));
64
139
  }
65
- let request = protocol::Request::new(number, options, limits, process)?;
140
+ let request = protocol::Request::new(operation, limits, process)?;
141
+ let maximum = u64::try_from(request.operation.maximum()?)
142
+ .map_err(|_| Error::InvalidPdfOption("worker output limit is too large"))?;
66
143
  let directory = tempfile::tempdir()?;
67
144
  let input_path = directory.path().join("input.pdf");
68
145
  File::create(&input_path)?.write_all(data)?;
@@ -77,30 +154,20 @@ pub fn render_page_isolated(
77
154
  .process_group(0)
78
155
  .spawn()?;
79
156
  let mut child = Running { child, finished: false };
80
- let status = supervise(&mut child, &budget, deadline, process.memory_bytes)?;
81
- if !status.success() {
82
- return Err(Error::LimitExceeded("render worker resources"));
83
- }
84
- let maximum = options
85
- .max_output_bytes
86
- .max(protocol::MAX_ERROR_BYTES)
87
- .checked_add(protocol::HEADER.len())
88
- .ok_or(Error::InvalidPdfOption("output limit is too large"))?;
89
- let maximum = u64::try_from(maximum).unwrap_or(u64::MAX);
157
+ let failure = match supervise(&mut child, &budget, deadline, process.memory_bytes) {
158
+ Ok(status) if status.success() => check_finished(&budget, deadline).err(),
159
+ Ok(_) => Some(Error::LimitExceeded("PDF worker resources")),
160
+ Err(Error::Cancelled) => return Err(Error::Cancelled),
161
+ Err(error) => Some(error),
162
+ };
163
+ // Stop and reap an unfinished worker before inspecting its flushed output.
164
+ drop(child);
90
165
  if fs::metadata(&output_path)?.len() > maximum {
91
- return Err(Error::LimitExceeded("render worker output"));
166
+ return Err(Error::LimitExceeded("PDF worker output"));
92
167
  }
93
168
  let mut output = Vec::new();
94
169
  File::open(output_path)?.take(maximum).read_to_end(&mut output)?;
95
- check_finished(&budget, deadline)?;
96
- let data = protocol::decode(output, options.max_output_bytes)?;
97
- // The trusted worker writes only PNG; also reject malformed/empty replies.
98
- if !data.starts_with(b"\x89PNG\r\n\x1a\n") || data.len() < 26 {
99
- return Err(Error::malformed("render worker", "invalid PNG response"));
100
- }
101
- let dimension = |at| u32::from_be_bytes(data[at..at + 4].try_into().unwrap_or([0; 4]));
102
- let (width, height) = (dimension(16), dimension(20));
103
- options.image(data, number, (width, height))
170
+ Ok(Output { data: output, failure })
104
171
  }
105
172
 
106
173
  fn check(budget: &Budget<'_>, deadline: Instant) -> Result<()> {
@@ -155,12 +222,34 @@ fn supervise(
155
222
  check(budget, deadline)?;
156
223
  #[cfg(target_os = "macos")]
157
224
  if let Err(error) = system::check_memory(child.child.id(), memory) {
158
- // It may have exited between try_wait and the memory query.
159
- if let Some(status) = child.poll()? {
160
- return Ok(status);
225
+ if matches!(error, Error::Io(_)) {
226
+ return finish_monitor_error(child, budget, deadline, error);
161
227
  }
162
228
  return Err(error);
163
229
  }
164
230
  std::thread::sleep(Duration::from_millis(5));
165
231
  }
166
232
  }
233
+
234
+ #[cfg(target_os = "macos")]
235
+ fn finish_monitor_error(
236
+ child: &mut Running,
237
+ budget: &Budget<'_>,
238
+ deadline: Instant,
239
+ error: Error,
240
+ ) -> Result<ExitStatus> {
241
+ // proc_pidinfo can stop seeing an exiting task before waitpid can reap it.
242
+ // Never bypass monitoring for a live worker: allow only a bounded exit
243
+ // grace, still respecting cancellation and the original overall deadline.
244
+ let grace = Instant::now() + Duration::from_millis(50);
245
+ loop {
246
+ if let Some(status) = child.poll()? {
247
+ return Ok(status);
248
+ }
249
+ check(budget, deadline)?;
250
+ if Instant::now() >= grace {
251
+ return Err(error);
252
+ }
253
+ std::thread::sleep(Duration::from_millis(5));
254
+ }
255
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -326,8 +326,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
326
326
  native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
327
327
  native.define_singleton_method("content", function!(read::content_native, 5))?;
328
328
  native.define_singleton_method("images", function!(read::images_native, 4))?;
329
- native.define_singleton_method("pdf_info", function!(pdf::info, 2))?;
330
- native.define_singleton_method("pdf_pages", function!(pdf::pages, 3))?;
329
+ native.define_singleton_method("pdf_info", function!(pdf::info, 4))?;
330
+ native.define_singleton_method("pdf_pages", function!(pdf::pages, 5))?;
331
331
  native.define_singleton_method("render_pdf_page", function!(pdf::render, 5))?;
332
332
  native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
333
333
  let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;
@@ -9,9 +9,17 @@ use crate::read::without_gvl;
9
9
 
10
10
  type Keywords = (Option<usize>, Option<u64>, Option<f64>);
11
11
 
12
- pub fn info(ruby: &Ruby, document: RString, keywords: Keywords) -> Result<RString, Error> {
12
+ pub fn info(
13
+ ruby: &Ruby,
14
+ document: RString,
15
+ keywords: Keywords,
16
+ worker: String,
17
+ memory_bytes: u64,
18
+ ) -> Result<RString, Error> {
19
+ let worker = PathBuf::from(worker);
20
+ let process = process_limits(memory_bytes);
13
21
  let value = without_gvl(ruby, document, keywords, |data, limits| {
14
- let info = pdf::info(data, limits)?;
22
+ let info = pdf::isolation::info_isolated(&worker, data, limits, &process)?;
15
23
  Ok(json!({ "page_count": info.page_count, "encrypted": info.encrypted }).to_string())
16
24
  })?;
17
25
  Ok(ruby.str_new(&value))
@@ -22,12 +30,16 @@ pub fn pages(
22
30
  document: RString,
23
31
  keywords: Keywords,
24
32
  options: (u32, u32, usize, usize, usize),
33
+ worker: String,
34
+ memory_bytes: u64,
25
35
  ) -> Result<RString, Error> {
26
36
  let (first_page, last_page, max_pages, max_page_bytes, max_output_bytes) = options;
27
37
  let options =
28
38
  pdf::ReadOptions { first_page, last_page, max_pages, max_page_bytes, max_output_bytes };
39
+ let worker = PathBuf::from(worker);
40
+ let process = process_limits(memory_bytes);
29
41
  let value = without_gvl(ruby, document, keywords, |data, limits| {
30
- let result = pdf::pages(data, &options, limits)?;
42
+ let result = pdf::isolation::pages_isolated(&worker, data, &options, limits, &process)?;
31
43
  let pages: Vec<_> = result
32
44
  .pages
33
45
  .into_iter()
@@ -63,11 +75,14 @@ pub fn render(
63
75
  let (page, max_side, max_pixels, max_output_bytes, grayscale) = options;
64
76
  let worker = PathBuf::from(worker);
65
77
  let options = pdf::RenderOptions { max_side, max_pixels, max_output_bytes, grayscale };
66
- let process =
67
- pdf::isolation::ProcessLimits { memory_bytes, ..pdf::isolation::ProcessLimits::default() };
78
+ let process = process_limits(memory_bytes);
68
79
  let data = without_gvl(ruby, document, keywords, |data: &[u8], limits: &Limits| {
69
80
  pdf::isolation::render_page_isolated(&worker, data, page, &options, limits, &process)
70
81
  .map(|image| image.data)
71
82
  })?;
72
83
  Ok(ruby.str_from_slice(&data))
73
84
  }
85
+
86
+ fn process_limits(memory_bytes: u64) -> pdf::isolation::ProcessLimits {
87
+ pdf::isolation::ProcessLimits { memory_bytes, ..pdf::isolation::ProcessLimits::default() }
88
+ }
@@ -3,16 +3,18 @@
3
3
  module Imprimatur
4
4
  # Keyword handling only; parsing, limits and rendering live in the native core.
5
5
  module PdfReader
6
- def self.info(document, max_bytes: nil, **limits)
7
- JSON.parse(Native.pdf_info(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits)),
6
+ def self.info(document, memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, **limits)
7
+ JSON.parse(Native.pdf_info(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits),
8
+ RENDER_WORKER, memory_bytes),
8
9
  symbolize_names: true)
9
10
  end
10
11
 
11
12
  def self.pages(document, first_page: 1, last_page: 0xFFFFFFFF, max_pages: 500,
12
13
  max_page_bytes: 256 * 1024, max_output_bytes: 8 * 1024 * 1024,
13
- max_bytes: nil, **limits)
14
+ memory_bytes: 1024 * 1024 * 1024, max_bytes: nil, **limits)
14
15
  options = [first_page, last_page, max_pages, max_page_bytes, max_output_bytes]
15
- JSON.parse(Native.pdf_pages(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits), options),
16
+ JSON.parse(Native.pdf_pages(Reader.read(document, max_bytes), Reader.limits(max_bytes:, **limits),
17
+ options, RENDER_WORKER, memory_bytes),
16
18
  symbolize_names: true)
17
19
  end
18
20
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.6.0'
4
+ VERSION = '0.6.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imprimatur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -114,6 +114,7 @@ files:
114
114
  - ext/imprimatur_native/core/src/read/pdf/images.rs
115
115
  - ext/imprimatur_native/core/src/read/pdf/isolation.rs
116
116
  - ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs
117
+ - ext/imprimatur_native/core/src/read/pdf/isolation/stream.rs
117
118
  - ext/imprimatur_native/core/src/read/pdf/isolation/system.rs
118
119
  - ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs
119
120
  - ext/imprimatur_native/core/src/read/pdf/layout.rs