mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
## 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 -->
92 lines
4.8 KiB
Rust
92 lines
4.8 KiB
Rust
//! Other files to combine with or copy pages from: opening them, and why one can't be used.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::{EditError, SourceProblem};
|
|
|
|
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
|
|
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
|
|
|
|
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
|
|
/// separate (old fully-qualified name → new one), in order across the sources.
|
|
#[derive(Debug)]
|
|
pub struct Combined {
|
|
pub bytes: Arc<Vec<u8>>,
|
|
pub renamed_fields: Vec<(String, String)>,
|
|
}
|
|
|
|
/// Parse another PDF to copy pages from.
|
|
pub(crate) fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
|
|
open_source_with(name, bytes, None)
|
|
}
|
|
|
|
/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file.
|
|
pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
|
|
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
|
}
|
|
|
|
/// [`open_source`] for pages inserted into `dest`. A source that withholds page extraction but
|
|
/// allows assembly is accepted when `dest` will be saved under the very same protection (file
|
|
/// key and `/Encrypt` values), as when pages cut or copied from a document are pasted back into
|
|
/// it: they never leave its protection (#5081).
|
|
pub(crate) fn open_source_into(name: &str, bytes: &Arc<Vec<u8>>, dest: &pdfcraft_cos::Document) -> Result<pdfcraft_cos::Document, EditError> {
|
|
let same_protection = |src: &pdfcraft_cos::Document| match (src.security(), dest.output_handler()) {
|
|
(Some(s), Some(d)) => s.file_key() == d.file_key() && s.dict() == d.dict(),
|
|
_ => false,
|
|
};
|
|
let permitted = |src: &pdfcraft_cos::Document| src.permissions().is_none_or(|p| p.extract_pages() || (p.assemble() && same_protection(src)));
|
|
source_document_if(bytes, None, permitted).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
|
}
|
|
|
|
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
|
|
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
|
|
if pdfcraft_organize::import_comments(doc, src)? == 0 {
|
|
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse another PDF to import comments from, whatever its permissions allow.
|
|
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
|
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
|
|
Ok(Ok(d)) => Ok(d),
|
|
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
|
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
|
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
|
}
|
|
}
|
|
|
|
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
|
source_document_if(bytes, password, |d| d.permissions().is_none_or(|p| p.extract_pages()))
|
|
}
|
|
|
|
/// Open `bytes` as a page source, refused as [`SourceProblem::NotPermitted`] unless `permitted`.
|
|
fn source_document_if(
|
|
bytes: &Arc<Vec<u8>>,
|
|
password: Option<&str>,
|
|
permitted: impl Fn(&pdfcraft_cos::Document) -> bool,
|
|
) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
|
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
|
Ok(Ok(d)) if !permitted(&d) => Err(SourceProblem::NotPermitted),
|
|
Ok(Ok(d)) => Ok(d),
|
|
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
|
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
|
|
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
|
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
|
}
|
|
}
|
|
|
|
/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not
|
|
/// why: what Combine would refuse it for, so the Combine files list can say so before Combine
|
|
/// is pressed. The owner (permissions) password lifts the restriction on copying pages.
|
|
pub fn combine_source_check(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<(), SourceProblem> {
|
|
source_document(bytes, password).map(|_| ())
|
|
}
|
|
|
|
/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever
|
|
/// its permissions allow; `None` when it can't be read.
|
|
pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<usize> {
|
|
let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?;
|
|
pdfcraft_organize::page_count(&doc).ok()
|
|
}
|