diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4efb31105..593ae6e68 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,13 +13,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | @@ -285,7 +285,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | | `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7f70df3f8..3f42a5401 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a" +sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,18 +63,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639" +sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799" +sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -135,12 +135,12 @@ sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52" +sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5" +sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -292,7 +292,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8" +sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" [[asset]] @@ -304,7 +304,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000" +sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,12 +3421,12 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5" +sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3537,4 +3537,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593" +sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 212454973..21bde353e 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -626,13 +626,17 @@ impl Automation { pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; let list: Vec = found .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875..522705bb1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -435,7 +435,7 @@ impl Automation { "doc_revisions" => self.doc_revisions(&a)?, "text_lines" => { let page = self.page(&a)?; - let doc = self.doc(&a)?; + let doc = self.doc_to_copy(&a)?; let info = &doc.info.pages[page]; let r = |x: f32| (x as f64 * 100.0).round() / 100.0; let lines: Vec = doc @@ -709,7 +709,11 @@ impl Automation { let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let after = self.doc(&a)?.text_blocks(page); + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); // Only a paragraph that now shows exactly the requested text is the edited one. A // neighbour that overlaps the old box (overlaid text, a text layer) must not take // its number, and neither must a paragraph the edit merged it into: rewriting that @@ -717,7 +721,10 @@ impl Automation { let edited = block_overlapping(&after, moved) .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } } let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); if !stale.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9ce6b12d4..4fff59c07 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1172,7 +1172,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1332,7 +1332,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0ebf3a9bc..778deaaec 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2130,6 +2130,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs index 6f27a6096..48498aefa 100644 --- a/crates/cos/examples/protect.rs +++ b/crates/cos/examples/protect.rs @@ -1,4 +1,17 @@ //! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER` +/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the +/// engine's `entropy` uses it), so every run encrypts with a different key. +fn seed() -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") }; @@ -15,7 +28,7 @@ fn main() { owner_password: owner, permissions: -1, encrypt_metadata: true, - seed: [1; 32], + seed: seed(), }) .unwrap(); std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap(); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index ae00234d0..c3f6c6b74 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -858,7 +858,7 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true), + Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), _ => o, } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a14..12d08b8dc 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,42 +88,104 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } -/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, -/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object { +/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. +fn drop_crypt_filter(dict: &mut Dict) { + let i = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), + Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), + _ => None, + }; + let Some(i) = i else { return }; + let mut filters = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + if i < filters.len() { + filters.remove(i); + } + // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). + match dict.get(b"DecodeParms").cloned() { + Some(Object::Array(mut parms)) if i < parms.len() => { + parms.remove(i); + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + Some(Object::Dict(_)) if i == 0 => { + dict.remove(b"DecodeParms"); + } + _ => {} + } + if filters.is_empty() { + dict.remove(b"Filter"); + if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { + dict.remove(b"DecodeParms"); + } + } else { + dict.set(b"Filter".to_vec(), Object::Array(filters)); + } +} + +/// What [`transform`] does to an object. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Decrypt, with the handler the document was opened with. + Decrypt, + /// Encrypt with the handler the document was opened with: streams keep their crypt filters. + Encrypt, + /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from + /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the + /// stream follows the new policy instead of reading as `/Identity` and staying in clear. + EncryptReplacing, +} + +/// Decrypt or encrypt every string and stream in an indirect object, except a signature +/// dictionary's `/Contents` (see [`is_signature_dict`]). +pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, decrypt) + walk(o, &strings, h, num, generation, pass) } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object { +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()), + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), Object::Dict(d) if is_signature_dict(d) => Object::Dict( d.iter() .map(|(k, v)| match (k.as_slice(), v) { (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)), + _ => (k.clone(), walk(v, strings, h, num, generation, pass)), }) .collect(), ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), Object::Stream(s) => { - let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect(); + let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); let ty = s.dict.name(b"Type"); if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { return Object::Stream(Stream { dict, raw: s.raw.clone() }); } - let named = named_crypt_filter(&s.dict); + let mut named = named_crypt_filter(&s.dict); + if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { + drop_crypt_filter(&mut dict); + named = None; + } let kind = match (&named, ty) { (Some(n), _) => StreamKind::Named(n), (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 3db0566ff..b151399fe 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)), + (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; + Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + } _ => Cow::Borrowed(o), } } diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index e05161558..2c6aad244 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -2,6 +2,7 @@ //! with our reader and with an independent one (hayro-syntax's decryption). Also editing //! encrypted documents incrementally, changing and removing passwords, and permissions. +use std::io::Write; use std::sync::Arc; use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; @@ -9,11 +10,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; fn fixture() -> Vec { + fixture_with("") +} + +/// The fixture with `extra` added to the content stream's dictionary. +fn fixture_with(extra: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()), + format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), "<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta\nendstream".replace("22", "26"), "<< /Title (Quarterly numbers) >>".to_string(), @@ -209,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() { #[test] fn qpdf_agrees() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); - let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = private_temp_dir("pdfcraft-enc"); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); - std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); + std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); @@ -225,6 +230,35 @@ fn qpdf_agrees() { let _ = std::fs::remove_dir_all(dir); } +/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState` +/// is seeded by the operating system), created by this call or not at all. +fn private_temp_dir(prefix: &str) -> std::path::PathBuf { + use std::hash::{BuildHasher, Hasher}; + let mut name = std::collections::hash_map::RandomState::new().build_hasher(); + name.write_u32(std::process::id()); + let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); + #[cfg(unix)] + let builder = { + let mut builder = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + builder.create(&dir).unwrap(); + dir +} + +/// Oracle files go in a directory this test created: another local account cannot have +/// prepared it (or symlinks inside it) in advance. +#[test] +fn oracle_files_go_in_a_fresh_directory() { + let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe")); + assert_ne!(a, b); + assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0); + let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b)); +} + /// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place /// after hashing the file around it. Its other strings, and the `/Contents` of anything that /// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched. @@ -279,6 +313,75 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + +/// Protecting a document puts every stream under the new handler. A stream that selects a +/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an +/// inherited name (or `/Identity`) would leave the stream in clear. +#[test] +fn protecting_drops_inherited_crypt_filter_selectors() { + for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] { + let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap(); + assert_eq!(content(&doc), SECRET, "{selector}"); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted"); + assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}"); + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}"); + } + // Under the handler the document was opened with, `/Identity` is the author's choice (a + // stream meant to stay readable) and is kept. + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + let contents = page.reference(b"Contents").unwrap(); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), Object::name("Crypt")); + doc.set(contents, Object::Stream(s)); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks"); + assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f679cf87c..f7d11d47e 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. + // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step + // that didn't remove is: the file would be passed on as cleaned with the data still in it. + let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), + Err(e) if removes => return Err(e), Err(e) => log.push(format!("{}: {e}", step.label())), } Ok(None) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 50508e20e..c23db91f1 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -46,10 +46,13 @@ impl crate::Document { } impl Session { - /// Compare files: the text differences from document `old` to document `new`. + /// Compare files: the text differences from document `old` to document `new`. The result + /// carries both documents' text, so both must allow copying it. pub fn compare(&self, old: DocId, new: DocId) -> Result { let a = self.get(old).ok_or(EditError::NoDocument)?; let b = self.get(new).ok_or(EditError::NoDocument)?; + a.check_copying()?; + b.check_copying()?; Ok(pdfcraft_compare::compare(&a.words(), &b.words())) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596..817eb79a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -232,6 +232,10 @@ pub struct DocId(pub u64); /// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. const MAX_UNDO: usize = 100; +/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 +/// clear) is printed at. +const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; + /// The passwords that go with one state of a document (protection can change them). #[derive(Clone, Debug, Default, PartialEq)] struct Keys { @@ -4522,17 +4526,22 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); // Print as image: the pages used become pictures first, then the picture document is // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = &settings.raster { + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { let mut distinct: Vec = Vec::new(); for &p in &settings.pages { if !distinct.contains(&p) { distinct.push(p); } } - let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); - let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; let remapped = print::Settings { pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 117c8e14c..4ed589586 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -1,25 +1,33 @@ //! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page //! sent to the printer as a picture at a chosen resolution, for printers that skip text, //! shading or transparency. The pages are rendered the way the viewer shows them (reading -//! colours off, the print's own colours), made into an image-only document, and that document -//! is imposed on the paper like any other. The print is no longer searchable text. +//! colours off, the print's own colours) with only what the print's content choice keeps, made +//! into an image-only document, and that document is imposed on the paper like any other. The +//! print is no longer searchable text. +use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; -use pdfcraft_print::Raster; +use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; -use crate::Document; use crate::export::encode_png; +use crate::{Document, guard}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one -/// page per entry, the size of the source page as displayed. Pages too large for the renderer -/// at `raster.dpi` render at the largest resolution it allows. -pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { +/// page per entry, the size of the source page as displayed, drawing what `content` prints. +/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> { let dpi = raster.dpi.clamp(72.0, 600.0); let src = doc.export_source(); let mut config = src.config.clone(); - config.hide_comments = hide_comments; - let mut renderer = PageRenderer::new(src.bytes, config); + // The copy below holds only what prints, so the viewer's own comment hiding plays no part. + config.hide_comments = false; + let printed = guard(|| -> Result, String> { + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + pdfcraft_print::keep_printed(&mut cos, content); + write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + })??; + let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; for &page in pages { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0df176226..3d347e649 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1311,6 +1311,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); assert!(s.get(oid).unwrap().allows_copying()); assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Comparing reads both documents' text out, into the result, the report and the comments. + let refused = || Err(EditError::NotPermitted("copying text and images")); + assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused())); + assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); + assert!(s.compare(oid, oid).is_ok()); + assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); @@ -1334,6 +1340,115 @@ fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); } +/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the +/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with +/// no text or vector artwork left; the owner password lifts the limit. +#[test] +fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { + let bytes = protected("", "owner", -1 ^ (1 << 11)); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let width = f64::from(s.get(low).unwrap().info.pages[0].width); + let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { + let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); + assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); + let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap(); + let widths: Vec = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"), + _ => None, + }) + .collect(); + assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt"); + } + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + let printed = s.print_pdf(owner, &actual).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap(); + assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); +} + +/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content +/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow). +fn print_content_pdf() -> Vec { + let content = "0 0 1 rg 0 300 100 100 re f"; + let ap = |colour: &str| { + let ops = format!("{colour} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len()) + }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\ + 8 0 obj {} endobj\n\ + 9 0 obj {} endobj\n\ + 10 0 obj {} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + ap("0 1 0"), + ap("1 0 0"), + ap("1 1 0"), + ) + .into_bytes() +} + +/// Low-resolution printing prints what the chosen content prints when the pages themselves are +/// printed: form fields only leaves out the page content and every comment, the document alone +/// leaves out the comments, and the document with stamps keeps only the stamps. +#[test] +fn low_resolution_printing_prints_only_the_chosen_content() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ (1 << 11), + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour. + let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])]; + let all: &[&str] = &["content", "field", "stamp", "comment"]; + for (content, shown) in [ + (print::Content::DocumentAndMarkups, all), + (print::Content::Document, &["content", "field"][..]), + (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]), + (print::Content::FormFieldsOnly, &["field"][..]), + ] { + let settings = print::Settings { + pages: vec![0], + paper: (100.0, 400.0), + layout: print::Layout::Size(print::SizeMode::Actual), + content, + ..Default::default() + }; + for (id, how) in [(owner, "pages"), (low, "pictures")] { + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (100, 400), "{content:?} as {how}"); + for (band, y, colour) in bands { + let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap(); + let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40); + let expected = shown.contains(&band); + assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}"); + } + } + } +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, @@ -4236,6 +4351,24 @@ fn actions_run_their_steps_on_files() { assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } +/// A removal step that fails (here: a signed file can't be rewritten without its hidden data) +/// fails the action, so the file is not passed on as cleaned. +#[test] +fn actions_fail_when_hidden_information_could_not_be_removed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); + let built = actions::builtin(); + let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); + let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + assert!(e.starts_with("Sanitize document:"), "{e}"); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7a3d768b2..2a91075d6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -27,9 +27,11 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; +use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. @@ -1450,8 +1452,46 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 18f6f8f8f..680f82faa 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -313,6 +313,23 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { use crate::Limits; use crate::xfa::*; @@ -398,6 +415,22 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. pub(super) fn dated_form() -> XfaNode { let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index e77db241f..03668c870 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1477,7 +1477,10 @@ pub(crate) fn warm_up() { /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 0b727449c..f6a98c4fe 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -31,16 +31,16 @@ pub fn create_staging( create_staging_as(dir, name, form, suffixes, None) } -/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so its mode is never more open than that -/// of the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on +/// Unix. It is created readable and writable by its owner only and then given them, so its mode +/// is never more open than that of the file it will replace, not even while it is still empty (a +/// handle opened then would keep reading what is written later). The owner is the saver's. fn create_staging_as( dir: &Path, name: &str, form: StagingName, suffixes: impl IntoIterator, - keep: Option<&std::fs::Permissions>, + keep: Option<&std::fs::Metadata>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -68,7 +68,7 @@ fn create_staging_as( if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. - let _ = file.set_permissions(keep.clone()); + let _ = file.set_permissions(kept_permissions(&file, keep)); } return Ok((tmp, file)); } @@ -81,6 +81,26 @@ fn create_staging_as( Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits +/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver +/// isn't a member), group and others get nothing: someone the group bits shut out would +/// otherwise read the file as one of the others. +#[cfg(unix)] +fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let mut mode = keep.permissions(); + let joined = file.metadata().is_ok_and(|m| m.gid() == keep.gid()) || std::os::unix::fs::fchown(file, None, Some(keep.gid())).is_ok(); + if !joined { + mode.set_mode(mode.mode() & !0o077); + } + mode +} + +#[cfg(not(unix))] +fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + keep.permissions() +} + /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -98,7 +118,7 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file -/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied @@ -126,9 +146,9 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. #[cfg(unix)] - let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + let keep = std::fs::metadata(path).ok(); #[cfg(not(unix))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). @@ -583,6 +603,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A replaced file keeps its group as well as its mode, so group permissions never reach a + /// group that couldn't read the file before (the saver's own group, by default). + #[cfg(target_os = "linux")] + #[test] + fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); + } + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation /// date where the system lets a program set it. #[cfg(any(windows, target_os = "macos"))] diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938..6839e5f6b 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -696,6 +696,25 @@ fn decoded_contents(doc: &Document, page: &Dict) -> Vec { out } +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + /// Does annotation `d` print under `content`? fn prints(doc: &Document, d: &Dict, content: Content) -> bool { const HIDDEN: i64 = 2; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 57c9f95fd..82a801dee 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -378,6 +378,7 @@ pub const WORDINGS: &[&str] = &[ "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", // Why a certificate that matched the issuer was not used to build the chain. "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", + "The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.", "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", @@ -400,6 +401,7 @@ pub const WORDINGS: &[&str] = &[ "The timestamp token could not be read ({}).", "The document has been altered or corrupted since the timestamp was applied.", "The timestamp authority's certificate was not valid at the time of timestamping.", + "The timestamp authority's certificate is not issued for time stamping.", "This document has not been modified since this timestamp was applied.", "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", "The document has been altered since this timestamp was applied in ways it does not permit ({}).", @@ -770,13 +772,16 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf // own claimed time, as without a timestamp. let mut pool = crate::timestamp::token_certs(raw); pool.extend(trust.certs.iter().cloned()); - let trusted_tsa = t - .signer_certificate() - .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa { + let tsa = t.signer_certificate(); + let trusted_tsa = tsa + .as_ref() + .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); + if !trusted_tsa { + unverified_time = Some(t.gen_time); + } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { info.timestamp_time = Some(t.gen_time); } else { - unverified_time = Some(t.gen_time); + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); } } } @@ -1084,6 +1089,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig info.status = Status::Unknown; info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping.")); } + if !cert.may_timestamp() { + info.status = Status::Unknown; + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); + } } info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -1111,9 +1120,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig pool.extend(trust.certs.iter().cloned()); // A trusted authority whose certificate was not valid at the time it stamped proves // nothing either (its detail is already recorded above): never upgrade that to Valid. - let in_validity = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time)); + // Nor does one whose certificate is not issued for time stamping. + let may_stamp = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time) && c.may_timestamp()); let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa && in_validity { + if trusted_tsa && may_stamp { info.status = Status::Valid; info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted.")); } else if trusted_tsa { @@ -1286,6 +1296,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod disallowed.push(what); } } + // The trailer chooses the catalog. A later revision that points it at another catalog (one + // already in the signed file, so no object changes) shows another document. + if doc.root() != old.root() { + disallowed.push(kind::STRUCTURE); + } for num in doc.object_numbers() { if same_place(num) { continue; diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 8f9dac0cd..b4a9d820f 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -259,26 +259,27 @@ impl OcspResponse { /// response window. pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus { // Who signed the response? The issuer identified by responderID, or a delegated, - // embedded responder identified the same way. - let responder: Option<&Certificate> = match &self.responder_id { + // embedded responder identified the same way. Which of the two it is comes from that + // choice, never from the embedded certificate's names, which anyone can copy. + let (responder, delegated): (Option<&Certificate>, bool) = match &self.responder_id { ResponderId::ByName(name) => { if *name == issuer.subject.raw { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *name == c.subject.raw) + (self.responder_cert.as_ref().filter(|c| *name == c.subject.raw), true) } } ResponderId::ByKey(hash) => { if *hash == DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()]) { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])) + (self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])), true) } } }; let Some(responder) = responder else { return RevocationStatus::Unknown }; // A delegated responder must carry the OCSP-signing EKU and be issued by the issuer. - if responder.subject.raw != issuer.subject.raw { + if delegated { if responder.issuer.raw != issuer.subject.raw || !responder.signed_by(&issuer.public_key) { return RevocationStatus::Unknown; } diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 53c80e269..5dd03d086 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -165,6 +165,9 @@ const PROCESSED_EXTENSIONS: [&str; 11] = [ "1.3.6.1.5.5.7.1.3", ]; +/// id-kp-timeStamping (RFC 5280 §4.2.1.12), required of a timestamp authority (RFC 3161 §2.3). +const EKU_TIME_STAMPING: &str = "1.3.6.1.5.5.7.3.8"; + /// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection, /// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document /// signing. @@ -384,6 +387,18 @@ impl Certificate { } } + /// Whether this certificate may stamp trusted time (RFC 3161 §2.3): its extended key usage + /// includes timeStamping, its key usage (if any) allows digital signatures or + /// non-repudiation, and it has no critical extension PdfCraft doesn't process. A certificate + /// issued for another purpose proves who signed, not when. + pub fn may_timestamp(&self) -> bool { + /// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation). + const SIGNING_BITS: u16 = 0b11; + self.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_TIME_STAMPING)) + && self.key_usage.is_none_or(|u| u & SIGNING_BITS != 0) + && self.unknown_critical.is_empty() + } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. pub fn signing_problem_clause(&self) -> Option { @@ -492,7 +507,8 @@ pub fn subject_of(raw: &[u8]) -> Option { } /// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each -/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its +/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, it +/// has no critical extension PdfCraft doesn't process (unless it is self-signed), its /// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid /// then. Stops at a self-signed certificate. Certificates embedded in a document are in `pool` /// too, so an ordinary end-entity certificate must never be accepted as an issuer. @@ -522,6 +538,10 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // two names where it needs them. let why = if !c.may_issue() { Some("The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.") + } else if !c.unknown_critical.is_empty() && !c.is_self_signed() { + // What such an extension limits (name constraints, policy constraints) would + // go unchecked. A self-signed root's extensions are the trust store's business. + Some("The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.") } else if c.path_len.is_some_and(|n| below > n as usize) { Some("The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.") } else if at.is_some_and(|t| !c.valid_at(t)) { diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 5defd859d..1cd5f028a 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -340,3 +340,25 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); assert_eq!(status, Status::Valid, "{details:?}"); } + +/// A CA certificate with a critical extension PdfCraft does not enforce (here name constraints) +/// vouches for nothing: what that extension limits would go unchecked. A self-signed root's +/// extensions are the trust store's business, so such a root still anchors. +#[test] +fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { + let name_constraints = ext("2.5.29.30", &der::seq(&[])); + let r = root(); + let constrained = + issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let l = end_entity("Outside Its Names", &constrained); + let pool = [constrained.cert.clone(), r.cert.clone()]; + let (chain, why) = build_chain_noted(&l.cert, &pool, None); + assert_eq!(names(&chain), ["Outside Its Names"]); + assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); + let constrained_root = + issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let i = ca("Under Constrained Root", &constrained_root, None); + let l = end_entity("Leaf", &i); + let pool = [i.cert.clone(), constrained_root.cert.clone()]; + assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Under Constrained Root", "Name Constrained Root"]); +} diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index cd36c5277..f184d7161 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -355,10 +355,19 @@ fn ocsp(issuer: &pkcs12::DigitalId, status: RevocationStatus, this: Time, next: }; let single = der::seq(&[&cert_id, &status, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); let responder_id = der::tlv(tag::ctx(1), &issuer.certificate.subject.raw); - let tbs = der::seq(&[&responder_id, &this.encode(), &der::seq(&[&single])]); - let alg = issuer.key.signature_algorithm(DigestAlg::Sha256); - let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap(); - let basic = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]); + signed_ocsp(&issuer.key, &responder_id, None, &single, this) +} + +/// A successful OCSP response holding `single`, signed with `key` and naming its responder by +/// `responder_id`, with `certs` (the encoded `[0]` field) if given. +fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Option<&[u8]>, single: &[u8], at: Time) -> Vec { + let tbs = der::seq(&[responder_id, &at.encode(), &der::seq(&[single])]); + let alg = key.signature_algorithm(DigestAlg::Sha256); + let sig = key.sign(DigestAlg::Sha256, &tbs).unwrap(); + let basic = match certs { + Some(c) => der::seq(&[&tbs, &alg, &der::bit_string(&sig), c]), + None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), + }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } @@ -465,6 +474,33 @@ fn ocsp_responses_verify_and_match_the_certificate() { assert!(OcspResponse::parse(&error).is_err()); } +/// An embedded responder certificate answers for an issuer only when the issuer signed it for +/// OCSP signing. One that merely copies the issuer's name, found by its key hash, does not. +#[test] +fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let issuer = &id.certificate; + let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let impostor = + Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + assert_eq!(impostor.subject.raw, issuer.subject.raw); + let cert_id = der::seq(&[ + &DigestAlg::Sha1.algorithm(), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.issuer.raw])), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()])), + &der::uint(&issuer.serial), + ]); + let revoked = der::tlv(tag::ctx(1), &Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 }.encode()); + let single = der::seq(&[&cert_id, &revoked, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); + let by_key = der::tlv(tag::ctx(2), &der::octets(&DigestAlg::Sha1.digest(&[&impostor.public_key.key_bits()]))); + // RFC 6960's `[0] EXPLICIT SEQUENCE OF Certificate`, and the certificate directly in `[0]`. + for certs in [der::explicit(0, &der::seq(&[&impostor.raw])), der::explicit(0, &impostor.raw)] { + let forged = OcspResponse::parse(&signed_ocsp(&key, &by_key, Some(&certs), &single, this)).unwrap(); + assert_eq!(forged.check(issuer, issuer, mid_june()), RevocationStatus::Unknown); + } +} + // ── tolerance for the encodings found in old and unusual signers ─────────────────────────────── // RSA signatures over SHA-256("hello") by the key in rsa-aes.p12, made with OpenSSL 3 (see // tests/data/README.md): the standard DigestInfo, DigestInfo without the NULL parameter, the bare diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index e9db0b570..61517b53d 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -384,6 +384,38 @@ struct TestTsa { time: Time, } +/// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for +/// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. +fn tsa_id() -> DigitalId { + tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) +} + +/// [`tsa_id`] with these extended key usages. +fn tsa_id_with(ekus: &[&str]) -> DigitalId { + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); + let alg = key.preferred_digest(); + let sig_alg = key.signature_algorithm(alg); + let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; + let until = Time { year: 2036, ..from }; + let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); + let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); + let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); + let tbs = der::seq(&[ + &der::explicit(0, &der::int(2)), + &der::uint(&[0x31]), + &sig_alg, + &name.raw, + &der::seq(&[&from.encode(), &until.encode()]), + &name.raw, + &key.public_key().spki(), + &der::explicit(3, &der::seq(&[&eku])), + ]); + let signature = key.sign(alg, &tbs).unwrap(); + let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); + DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } +} + impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; @@ -403,10 +435,7 @@ impl TimestampAuthority for TestTsa { #[test] fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); assert!(signed.starts_with(&fixture()), "still an incremental update"); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -442,10 +471,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&fixture()), "an incremental update"); let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] }; @@ -479,10 +505,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let mut source = open(&fixture()); source .update_dict(source.root().unwrap(), |d| { @@ -558,10 +581,7 @@ fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap(); let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap(); @@ -1531,10 +1551,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 } }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap(); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -1753,10 +1770,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = fixture_timestamped_in_a_field(&tsa); let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let all = signatures(&open(&stamped), &stamped, &trust); @@ -1786,7 +1800,7 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { #[test] fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), // Before the TSA certificate's validity (it starts in October 2026). time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; @@ -1801,10 +1815,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Unknown, "{:?}", s.details); @@ -2035,10 +2046,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { - let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&base)); @@ -2471,3 +2479,47 @@ fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() { s.modification ); } + +/// A later revision that only points the trailer at another catalog already in the signed file +/// shows a different document while every signed object is unchanged. That is not permitted. +#[test] +fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { + let base = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + &[ + b"<< /Type /Catalog /Pages 8 0 R /AcroForm << /Fields [6 0 R] >> >>", + b"<< /Type /Pages /Kids [9 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 8 0 R /MediaBox [0 0 300 300] /Contents 10 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract void) Tj ET\nendstream", + ], + ); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { field: Some("Approval".into()), rect: None, ..opts() }).unwrap(); + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + let switched = edit_after(&signed, |doc| doc.trailer_mut().set(b"Root".to_vec(), oref(7))); + let s = signatures(&open(&switched), &switched, &trust).into_iter().find(|s| s.signed).expect("the signature is still listed"); + assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); +} + +/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued +/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor +/// a signature's embedded one. +#[test] +fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() { + let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); + let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap(); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] }; + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(s.timestamp); + assert_eq!(s.timestamp_time, None, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); +} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5e..dd2f497b8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2283,6 +2283,7 @@ action wizard Start Inicia The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). El certificat del signant no s'ha emès per signar documents (el seu ús estès de clau no inclou el propòsit de signatura de documents). The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificat del signant té extensions crítiques que PdfCraft no reconeix ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificat de {} no és un certificat de CA que pugui emetre certificats, de manera que no s'utilitza per avalar {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificat de {} no permet tants certificats de CA per sota seu (longitud de camí), de manera que no s'utilitza per avalar {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificat de {} no era vàlid en el moment de signar, de manera que no s'utilitza per avalar {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identitat del signant és desconeguda perquè no s'ha inclòs a la llista de certificats de confiança i cap dels seus certificats pare és un certificat de confiança. @@ -2302,6 +2303,7 @@ action wizard Start Inicia The timestamp token could not be read ({}). No s'ha pogut llegir el testimoni de marca de temps ({}). The document has been altered or corrupted since the timestamp was applied. El document ha estat alterat o s'ha corromput des que es va aplicar la marca de temps. The timestamp authority's certificate was not valid at the time of timestamping. El certificat de l'autoritat de marca de temps no era vàlid en el moment de marcar el temps. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Aquest document no s'ha modificat des que es va aplicar aquesta marca de temps. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El document s'ha modificat des que es va aplicar aquesta marca de temps, però els canvis són permesos ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El document ha estat alterat des que es va aplicar aquesta marca de temps d'una manera que no permet ({}). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf4..66cac29aa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2615,6 +2615,7 @@ animation effects Off Vypnuto The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo podpisu RSA nemá parametr NULL (starší podepisovací programy jej tak zapisují); přesto byl načten. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certifikát {} nepovoluje pod sebou tolik certifikátů CA (délka cesty), proto se nepoužije k ručení za {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certifikát {} není certifikát CA, který smí vydávat certifikáty, proto se nepoužije k ručení za {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certifikát {} má kritická rozšíření, která PdfCraft nezná, proto se nepoužije k ručení za {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certifikát {} nebyl v době podpisu platný, proto se nepoužije k ručení za {}. The checker found problems which may prevent the document from being fully accessible. Kontrola našla problémy, které mohou bránit plné přístupnosti dokumentu. The document has been altered or corrupted since the signature was applied. Dokument byl od připojení podpisu změněn nebo poškozen. @@ -2657,6 +2658,7 @@ animation effects Off Vypnuto The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identita podepisující osoby je neznámá, protože není ve vašem seznamu důvěryhodných certifikátů a žádný z jejích nadřazených certifikátů není důvěryhodný. The signer's identity is valid. Identita podepisující osoby je platná. The timestamp authority's certificate was not valid at the time of timestamping. Certifikát autority časových razítek nebyl v době vystavení razítka platný. + The timestamp authority's certificate is not issued for time stamping. Certifikát autority časových razítek není vystaven pro vydávání časových razítek. The timestamp contents are not hexadecimal. Obsah časového razítka není šestnáctkový. The timestamp has no valid byte range. Časové razítko nemá platný rozsah bajtů. The timestamp token could not be read ({}). Časové razítko nebylo možné načíst ({}). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd..73b0ffbf1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2359,6 +2359,7 @@ action wizard Start Iniciar The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificado del firmante tiene extensiones críticas que PdfCraft no reconoce ({}). # Why a certificate that matched the issuer was not used to build the chain. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificado de {} no es un certificado de CA que pueda emitir certificados, así que no se usa para avalar a {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. El certificado de {} tiene extensiones críticas que PdfCraft no reconoce, así que no se usa para avalar a {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificado de {} no admite tantos certificados de CA por debajo (longitud de ruta), así que no se usa para avalar a {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificado de {} no era válido en el momento de la firma, así que no se usa para avalar a {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identidad del firmante es desconocida porque no está en tu lista de certificados de confianza y ninguno de los certificados superiores es de confianza. @@ -2378,6 +2379,7 @@ action wizard Start Iniciar The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}). The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo. The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado. + The timestamp authority's certificate is not issued for time stamping. El certificado de la autoridad de sellado de tiempo no está emitido para sellar el tiempo. This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc29982..efcf3e3c6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,6 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2515,6 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f..e2476f196 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1735,6 +1735,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. @@ -1783,6 +1784,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b539..6836f3e0a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2503,6 +2503,7 @@ animation effects Off Wyłączone The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Certyfikat podpisującego nie jest wystawiony do podpisywania dokumentów (jego rozszerzone użycie klucza nie obejmuje podpisywania dokumentów). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Certyfikat podpisującego ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certyfikat podmiotu {} nie jest certyfikatem urzędu certyfikacji, który może wystawiać certyfikaty, dlatego nie poświadcza certyfikatu {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certyfikat podmiotu {} ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje, dlatego nie poświadcza certyfikatu {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certyfikat podmiotu {} nie dopuszcza tylu certyfikatów urzędów certyfikacji poniżej siebie (długość ścieżki), dlatego nie poświadcza certyfikatu {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certyfikat podmiotu {} nie był ważny w chwili podpisania, dlatego nie poświadcza certyfikatu {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Tożsamość podpisującego jest nieznana, ponieważ jego certyfikatu nie ma na liście zaufanych certyfikatów i żaden z certyfikatów nadrzędnych nie jest zaufany. @@ -2522,6 +2523,7 @@ animation effects Off Wyłączone The timestamp token could not be read ({}). Nie udało się odczytać tokenu znacznika czasu ({}). The document has been altered or corrupted since the timestamp was applied. Dokument został zmieniony lub uszkodzony po dodaniu znacznika czasu. The timestamp authority's certificate was not valid at the time of timestamping. Certyfikat urzędu znacznika czasu nie był ważny w chwili oznaczania czasem. + The timestamp authority's certificate is not issued for time stamping. Certyfikat urzędu znakowania czasem nie jest wystawiony do znakowania czasem. This document has not been modified since this timestamp was applied. Dokument nie był modyfikowany od dodania tego znacznika czasu. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokument zmodyfikowano po dodaniu tego znacznika czasu, ale zmiany są dozwolone ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokument zmieniono po dodaniu tego znacznika czasu w sposób, na który znacznik nie pozwala ({}). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b..dcbebae95 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2634,6 +2634,7 @@ animation effects Off Desativados The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. O DigestInfo da assinatura RSA não tem o parâmetro NULL (assinantes mais antigos o incluem); mesmo assim, ele foi lido. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. O certificado de {} não permite essa quantidade de certificados de AC abaixo dele (comprimento do caminho), então não é usado para atestar a validade de {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. O certificado de {} não é um certificado de AC autorizado a emitir certificados, então não é usado para atestar a validade de {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. O certificado de {} não era válido no momento da assinatura, então não é usado para atestar a validade de {}. The checker found problems which may prevent the document from being fully accessible. A verificação encontrou problemas que podem impedir que o documento seja totalmente acessível. The document has been altered or corrupted since the signature was applied. O documento foi alterado ou corrompido desde que a assinatura foi aplicada. @@ -2680,6 +2681,7 @@ animation effects Off Desativados The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. A identidade do signatário é desconhecida porque não foi incluída na sua lista de certificados confiáveis e nenhum dos certificados na cadeia acima dela é confiável. The signer's identity is valid. A identidade do signatário é válida. The timestamp authority's certificate was not valid at the time of timestamping. O certificado da autoridade de carimbo de data e hora não era válido no momento da emissão do carimbo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. O conteúdo do carimbo de data e hora não está em hexadecimal. The timestamp has no valid byte range. O carimbo de data e hora não tem um intervalo de bytes válido. The timestamp token could not be read ({}). Não foi possível ler o token de carimbo de data e hora ({}). diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc8..0003e3851 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1684,6 +1684,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo din semnătura RSA nu are parametrul NULL (semnatarii mai vechi îl scriu așa); a fost citită oricum. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certificatul lui {} nu permite atâtea certificate CA sub el (lungimea căii), așa că nu este folosit pentru a garanta pentru {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certificatul lui {} nu este un certificat CA care poate emite certificate, așa că nu este folosit pentru a garanta pentru {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certificatul lui {} nu era valabil în momentul semnării, așa că nu este folosit pentru a garanta pentru {}. The checker found problems which may prevent the document from being fully accessible. Verificatorul a găsit probleme care pot împiedica accesibilitatea completă a documentului. The document has been altered or corrupted since the signature was applied. Documentul a fost modificat sau deteriorat după aplicarea semnăturii. @@ -1729,6 +1730,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identitatea semnatarului este necunoscută, deoarece nu a fost inclusă în lista certificatelor de încredere și niciunul dintre certificatele părinte nu este de încredere. The signer's identity is valid. Identitatea semnatarului este validă. The timestamp authority's certificate was not valid at the time of timestamping. Certificatul autorității de marcare temporală nu era valabil în momentul marcării. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Conținutul marcajului temporal nu este hexazecimal. The timestamp has no valid byte range. Marcajul temporal nu are un interval de octeți valid. The timestamp token could not be read ({}). Tokenul de marcaj temporal nu a putut fi citit ({}). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b..87abe5129 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1724,6 +1724,7 @@ theme Appearance Giao diện The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo của chữ ký RSA không có tham số NULL (các trình ký cũ ghi như vậy); nó vẫn được đọc. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Chứng chỉ của {} không cho phép có nhiều chứng chỉ CA ở dưới nó như vậy (độ dài đường dẫn), nên nó không được dùng để bảo chứng cho {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Chứng chỉ của {} không phải là chứng chỉ CA được phép cấp chứng chỉ, nên nó không được dùng để bảo chứng cho {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Chứng chỉ của {} có các phần mở rộng quan trọng mà PdfCraft không nhận ra, nên nó không được dùng để bảo chứng cho {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Chứng chỉ của {} không hợp lệ tại thời điểm ký, nên nó không được dùng để bảo chứng cho {}. The checker found problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy các vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. The document has been altered or corrupted since the signature was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi chữ ký được áp dụng. @@ -1772,6 +1773,7 @@ theme Appearance Giao diện The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Danh tính của người ký không rõ vì nó chưa được đưa vào danh sách chứng chỉ tin cậy của bạn và không chứng chỉ cấp trên nào của nó là chứng chỉ tin cậy. The signer's identity is valid. Danh tính của người ký hợp lệ. The timestamp authority's certificate was not valid at the time of timestamping. Chứng chỉ của cơ quan cấp dấu thời gian không hợp lệ tại thời điểm đóng dấu thời gian. + The timestamp authority's certificate is not issued for time stamping. Chứng chỉ của cơ quan cấp dấu thời gian không được cấp cho mục đích đóng dấu thời gian. The timestamp contents are not hexadecimal. Nội dung dấu thời gian không phải là thập lục phân. The timestamp has no valid byte range. Dấu thời gian không có dải byte hợp lệ. The timestamp token could not be read ({}). Không thể đọc mã thông báo dấu thời gian ({}).