## What this changes
Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`.
Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it:
- At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that.
- New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes.
- `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON.
- `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR).
- Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR).
- The `organize.combine` parity notes now say "page extraction".
Fixes#5081
## How you verified it
`cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups):
- `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`.
- `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused.
- `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it.
- `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted.
- `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed.
**Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build.
- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase)
- [ ] tests pass on the crates I changed (crypt only)
## Anything a reviewer should look at first
- **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying.
- **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password.
- **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save.
- **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
- **New Features**
- Page extraction and splitting now follow a dedicated permission check, distinct from page assembly.
- Pages can be inserted from certain protected PDFs when their protection settings match the destination document.
- **Bug Fixes**
- Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted.
- Security details now report whether page extraction is permitted.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision
* Permissions: at revision 2, bits 11 and 12 mean nothing
Table 22 defines them for revision 3 and later. A revision 2 file with bit 4
clear and bit 11 set was given page changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A crypt filter whose /AuthEvent is /EFOpen needs its password only when
an embedded file is accessed (ISO 32000-2 7.6.6, Table 25). A document
whose strings and streams are in the clear (/StmF, /StrF /Identity) and
whose encrypting filters are all EFOpen ones, /EFF naming one, now opens
without a password in cos and in the renderer (hayro-syntax patch 36).
Without the password there is no file key: the embedded files' data is
held and saved as stored, and Document::embedded_files_locked and
reads_as_stored say so. Asking for such an attachment's bytes is an
error. A full save that would renumber data whose key mixes in the
object number (RC4, AES-128), or change the document's security, is
refused. The password still opens the document and its attachments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
The only content conflict was crates/engine/src/lib.rs: main moved
EditCtx and run_edit into edit.rs, while this branch taught Protect
to draw keys and salts from getrandom and fail closed. Keep main's
module split and apply that CSPRNG behaviour on EditCtx in edit.rs.
The regression test imports EditCtx and run_edit from the edit module.
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
/ID is SHA-256 of the seed under the label id, not an unkeyed DefaultHasher digest. The salts test is a characterization; failing closed when randomness is missing stays the regression guard.
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
Every string and stream decrypted searched the document's /CF filters
for the method by name. A file controls both the number of filters and
the number of strings, so the search multiplied the two when the
filter in use was listed last. The handler resolves the string, stream
and embedded-file methods when it is built, and looks named filters up
in a map.
AES-256 files (R5 and R6) store the permissions twice: in clear in /P,
and in /Perms, encrypted with the file key (ISO 32000-2, Algorithm 13).
We wrote /Perms but never read it, so a document opened with the user
password got whatever /P said, even when /P no longer matched /Perms.
permissions() now decrypts /Perms and, when it holds a valid block
(marked "adb"), counts a permission only if both /P and /Perms grant
it. Without a readable /Perms, /P applies as before. R2 to R4 need no
check: /P is part of their file key, so an edited /P does not open.
Encryption derives its keys from a prepared password, not the typed one:
AES-256 (R6) applies SASLprep, falls back to the password as given if
SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128
(R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the
padding starts with "(". Protection::validate checked only the typed
strings, so a permissions password of U+00AD wrote a file that opens with
owner rights and no password, and an open password "A" with a permissions
password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a
file whose open password lifts the restrictions.
pdfcraft-crypt gains effective_password(algorithm, password), the bytes the
keys are derived from (it and create share the private prepared_password
helper), re-exported from pdfcraft-cos. validate refuses a password whose
effective bytes are those of "", and an open and a permissions password
whose effective bytes are equal. Opening files is unchanged.
Fixes#1185.
A PDF protected with only a permissions (owner) password opens without
asking, restricted, and nothing let the user enter that password
afterwards: the security notice only led to a read-only Security tab and
the hint said to reopen the file with the password, which the app had no
way to do (#995, #1077).
Now the security notice and Document Properties > Security offer Unlock...,
and Change settings / Remove security on a restricted document ask for the
permissions password first and then carry on, as Acrobat does. The open
password is refused there.
- crypt: SecurityHandler::authenticate_owner accepts only the owner
password and refuses a different file key.
- cos: Document::unlock upgrades the open document in place; owner and user
passwords unlock the same file key, so nothing is decoded again.
- engine: Session::unlock (not an edit: no undo step, not marked unsaved)
also unlocks the undo and redo states and reopens saves as owner;
Document::can_unlock, EditError::WrongPassword, command protect.unlock.
- ui: the permissions password prompt shares the open prompt's modal; the
notice and Security tab get Unlock...; the disabled-command hints no
longer point at reopening the file.
- i18n: the new strings in all 15 full catalogs (catalog hashes updated).
Tests cover crypt, cos and engine (all four algorithms, wrong and open
passwords, undo, redo, save, removing security afterwards) and the UI
(notice flow, Remove security asking first).
Closes#995Closes#1077
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Revisions 2–4 of the standard security handler take the password in
PDFDocEncoding (ISO 32000-2 §7.6.4.3.2, Algorithm 2). PdfCraft authenticates
an encrypted file twice, and neither did that:
- pdfcraft-crypt took Latin-1 and dropped every other character, so "ı"
(0x9A) and "€" (0xA0) vanished and "ılık€" became "lk";
- the renderer (vendored hayro-syntax, "TODO: Convert to PDFDocEncoding")
used the UTF-8 bytes, so any non-ASCII password failed, even "Çok güzel",
which Latin-1 and PDFDocEncoding write alike;
- the engine handed the renderer an owner-recovered user password as Latin-1
characters, which the renderer then read as UTF-8 again.
Files that qpdf or Acrobat protected with such passwords could not be
opened with the right password.
- pdfcraft-crypt encodes R2–R4 passwords in PDFDocEncoding and, for a
password with characters PDFDocEncoding lacks (ğ, ş, İ), also tries its
UTF-8 bytes, which qpdf writes for those. The PDFDocEncoding table moves
here from pdfcraft-cos (which depends on this crate) and cos uses it.
- hayro-syntax (vendored patch 14) tries an R2–R4 password as given, then
in PDFDocEncoding.
- The engine passes a recovered user password to the renderer as UTF-8 when
its bytes are UTF-8 and as PDFDocEncoding otherwise, so the renderer gets
exactly those bytes back.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
AES blocks come from `as_chunks`, the IV from `split_first_chunk`, and the R6 hash takes its
AES key and IV with `first_chunk`, so no `expect` is needed. The crate now denies the panic lints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>