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 +4 -4
- data/CHANGELOG.md +15 -5
- data/Cargo.lock +4 -3
- data/Cargo.toml +2 -1
- data/README.md +18 -7
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +3 -2
- data/ext/imprimatur_native/core/README.md +6 -1
- data/ext/imprimatur_native/core/src/read/pdf/bounded.rs +94 -35
- data/ext/imprimatur_native/core/src/read/pdf/isolation/protocol.rs +145 -76
- data/ext/imprimatur_native/core/src/read/pdf/isolation/stream.rs +113 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/system.rs +27 -0
- data/ext/imprimatur_native/core/src/read/pdf/isolation/worker.rs +49 -18
- data/ext/imprimatur_native/core/src/read/pdf/isolation.rs +117 -28
- data/ext/imprimatur_native/ffi/Cargo.toml +1 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +2 -2
- data/ext/imprimatur_native/ffi/src/pdf.rs +20 -5
- data/lib/imprimatur/pdf_reader.rb +6 -4
- data/lib/imprimatur/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b79ecca5f76986eb4049090056dcee55fe7b8d2aafa1115de48ea2f45b0e12ef
|
|
4
|
+
data.tar.gz: 925d5c93f99da882db19b562c749ba41c1ae17a5fd01d17b66bd9be93a9dc15a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`
|
|
366
|
-
|
|
367
|
-
The gem bundles that worker and always
|
|
368
|
-
back to
|
|
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
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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`.
|
|
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
|
|
138
|
+
let mut individual = false;
|
|
118
139
|
for (number, page) in selected.pages {
|
|
119
140
|
budget.check()?;
|
|
120
141
|
if remaining == 0 {
|
|
121
|
-
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
//!
|
|
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\
|
|
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
|
|
16
|
-
pub
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
u64::from(self.
|
|
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
|
-
|
|
65
|
-
.
|
|
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
|
|
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 [
|
|
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 *
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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(
|
|
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("
|
|
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("
|
|
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(
|
|
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
|
-
//!
|
|
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::{
|
|
7
|
-
use
|
|
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
|
-
|
|
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
|
|
20
|
+
eprintln!("imprimatur PDF worker: {error}");
|
|
18
21
|
ExitCode::FAILURE
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
fn run() -> Result<
|
|
24
|
-
let
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//!
|
|
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()
|
|
63
|
-
return Err(Error::InvalidPdfOption("process limits
|
|
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(
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
+
}
|
|
@@ -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,
|
|
330
|
-
native.define_singleton_method("pdf_pages", function!(pdf::pages,
|
|
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(
|
|
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::
|
|
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::
|
|
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),
|
|
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
|
|
data/lib/imprimatur/version.rb
CHANGED
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.
|
|
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
|