Skip to main content

rightkit_pdf/
save.rs

1//! PDF editor save protocol (Dispatch C, lane C2): `prepare_pdf_save`'s structural recheck,
2//! save-token lifecycle, and `commit_pdf_save`'s atomic write — `docs/PDF-EDITOR-NOW.md` §10.
3//!
4//! Owns the two-phase save handshake described in §10: Rust composes and structurally verifies a
5//! candidate before ever handing it back (§10.1), the frontend independently renders/verifies it
6//! (§10.2, entirely frontend-side — nothing here), and only then does a short-lived, single-use
7//! token authorize one atomic disk write (§10.3). This file consumes the frozen
8//! `compose_candidate` seam (`pdf_compose.rs`, lane C1) and the shared wire types it depends on
9//! (`pdf_model.rs`, lane A1); it defines no new PDF-composition logic of its own.
10//!
11//! ## Judgment calls this file had to make (§10/§4/§14.1 leave these open)
12//!
13//! - **Byte channel shape for `prepare_pdf_save` (§4).** Incoming annotation bytes use base64 in
14//!   normal JSON because WKWebView cannot complete this command with Tauri's raw request body.
15//!   Candidate bytes return as a JSON byte array in the same response. This is larger than a raw
16//!   response but reliable in WKWebView, while the raw response path fails during stream decoding.
17//! - **No `AppState` exists in this codebase** (confirmed absent by `pdf_font.rs`'s and
18//!   `pdf_inspect.rs`'s own notes) — this module introduces [`PdfSaveTokenStore`] as the Tauri
19//!   `.manage()`-registered state for in-flight save tokens; wiring that `.manage()` call belongs
20//!   to whichever lane owns `lib.rs`, out of this file's scope.
21//! - **"Document session" (§10.3: "at most two candidates per document session"; §10.3: "token
22//!   ... is invalidated after any source revision")** is not a concept this codebase threads
23//!   anywhere yet, so both rules are scoped to `source_path`: at most two live tokens per source
24//!   path (oldest evicted first), and a *successful* [`commit_pdf_save`] for a path invalidates
25//!   every other outstanding token for that same path (the source's on-disk bytes just changed
26//!   out from under them).
27//! - **§11's frozen error taxonomy has no dedicated "token expired/unknown/replayed" variant.**
28//!   Every token-lifecycle failure in [`commit_pdf_save`] (unknown token, expired token, already-
29//!   consumed/replayed token, source changed since prepare) maps to
30//!   [`PdfEditError::WriteConflict`] ("target changed before save") as the closest existing
31//!   variant, per §14.1's own grouping of "expired/replayed/mismatched candidate token" as one
32//!   fixture category distinct from `StaleSource` (which §10.1 reserves for the *prepare-time*
33//!   source-SHA mismatch).
34//! - **Single-use enforcement removes the token record on the very first `commit_pdf_save` call
35//!   for it**, success or failure — a token that fails to commit (e.g. a same-path SHA race) is
36//!   dead, not retryable; the caller must re-run `prepare_pdf_save` against fresh source bytes.
37//!   This matches §10.3's "single-use" wording literally rather than only-consume-on-success.
38//! - **"Same path" is a caller-supplied comparison.** [`resolve_commit_with`] takes the decision as
39//!   a closure; [`resolve_commit`] compares the strings. A path alias (`dir/./memo.pdf`, a hard
40//!   link, a case variant) defeats the strings, so [`crate::commit::commit_pdf_save`] passes a
41//!   filesystem-identity comparison, and holds a per-destination lock across recheck, replacement
42//!   and sibling invalidation. [`stage_prepared_save_against_base`] additionally refuses a
43//!   candidate in which a page lost a resource it resolved in the bytes it was composed from.
44//! - **Structural recheck (§10.1 step 7: "lopdf reopens candidate, checks page count/resources/
45//!   content streams, & re-inspects expected text")** is implemented as: candidate parses via
46//!   `lopdf::Document::load_mem`, has at least one page, every page's `/Contents` (when present)
47//!   resolves without error, and every [`ExpectedTextCheck`] page has at least one text island
48//!   (via `pdf_inspect::walk_page_graph`, the same walker `inspect_pdf_page` uses) whose text
49//!   contains the expected substring. `ExpectedTextCheck` itself is a new type — §10.1 names the
50//!   field (`expected_text: Vec<ExpectedTextCheck>`) but never defines the type; this file defines
51//!   it as `{ page, text }`, the minimal shape `pdf_compose.rs` can populate per touched page and
52//!   this recheck (and the frontend's `verifyPdfCandidate`, per §10.2) can check against.
53
54use std::collections::{HashMap, VecDeque};
55use std::sync::Mutex;
56use std::time::{Duration, Instant};
57
58use serde::{Deserialize, Serialize};
59
60use crate::inspect::{content_stream_ids, walk_page_graph};
61use crate::model::{
62    document_sha256, effective_pdf_edit_commands, sha256_hex, PdfEditCommand, PdfEditError,
63};
64
65/// A save token is valid for this long after `prepare_pdf_save` issues it (§10.3).
66const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
67
68/// §10.3: "Keep at most two candidates per document session" (scoped to `source_path` — see
69/// module doc comment).
70const MAX_TOKENS_PER_SOURCE: usize = 2;
71
72// -------------------------------------------------------------------------------------------
73// Wire types this file owns (§10.1 names these fields but leaves their shapes to this lane)
74// -------------------------------------------------------------------------------------------
75
76/// One page-level assertion a composed candidate must satisfy before `prepare_pdf_save` will hand
77/// out a token for it: page `page` must contain `text` somewhere in its extracted text. Built from
78/// each [`PdfEditCommand`]'s "after" text, one entry per touched page.
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80#[serde(rename_all = "camelCase")]
81pub struct ExpectedTextCheck {
82    pub page: u32,
83    pub text: String,
84}
85
86/// JSON result returned by `prepare_pdf_save`. Candidate bytes use a numeric array because
87/// WKWebView's raw-response and large-base64 decoding paths are unreliable here.
88#[derive(Clone, Debug, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct PreparedPdfSaveMeta {
91    pub token: String,
92    pub candidate_sha256: String,
93    pub candidate_bytes: Vec<u8>,
94    pub affected_pages: Vec<u32>,
95    pub expected_text: Vec<ExpectedTextCheck>,
96}
97
98/// Result of a successful [`commit_pdf_save`].
99#[derive(Clone, Debug, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct PdfCommitResult {
102    pub target_path: String,
103    pub document_sha256: String,
104}
105
106/// JSON request for `prepare_pdf_save`; byte content is base64 rather than a numeric JSON array.
107#[derive(Debug, Clone, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct PreparePdfSaveRequestMeta {
110    pub source_path: String,
111    pub expected_source_sha256: String,
112    pub annotation_pdf_base64: String,
113    pub source_commands: Vec<PdfEditCommand>,
114}
115
116// -------------------------------------------------------------------------------------------
117// Token store (Tauri-managed state; see module doc comment on the missing `AppState`)
118// -------------------------------------------------------------------------------------------
119
120struct SaveTokenRecord {
121    source_path: String,
122    /// Source document SHA-256 at the moment this candidate was staged (i.e. the same value
123    /// `stage_prepared_save`'s caller already verified as `actual_source_sha256`). Stored on the
124    /// record itself so `commit_pdf_save`'s same-path recheck (§10.3) has a prepare-time value to
125    /// compare against without needing a separate session ledger.
126    source_sha256: String,
127    candidate_bytes: Vec<u8>,
128    created_at: Instant,
129}
130
131#[derive(Default)]
132struct TokenStoreInner {
133    tokens: HashMap<String, SaveTokenRecord>,
134    /// Insertion-ordered token ids per source path, oldest first — drives §10.3's two-candidate
135    /// cap.
136    by_source: HashMap<String, VecDeque<String>>,
137}
138
139/// In-memory save-token registry for the prepare/commit handshake (§10.3). One instance is
140/// managed as Tauri state (`app.manage(PdfSaveTokenStore::default())`, wired by whichever lane
141/// owns `lib.rs`) and shared across `prepare_pdf_save` and `commit_pdf_save`.
142#[derive(Default)]
143pub struct PdfSaveTokenStore {
144    inner: Mutex<TokenStoreInner>,
145}
146
147impl PdfSaveTokenStore {
148    /// Registers a newly composed+verified candidate under a fresh token, evicting the oldest
149    /// token for `source_path` if this would exceed [`MAX_TOKENS_PER_SOURCE`]. Returns the token.
150    fn insert(&self, source_path: &str, source_sha256: &str, candidate_bytes: Vec<u8>) -> String {
151        let token = random_save_token();
152        let mut inner = self.inner.lock().expect("token store mutex poisoned");
153
154        let mut evicted = Vec::new();
155        {
156            let queue = inner.by_source.entry(source_path.to_string()).or_default();
157            while queue.len() >= MAX_TOKENS_PER_SOURCE {
158                match queue.pop_front() {
159                    Some(oldest) => evicted.push(oldest),
160                    None => break,
161                }
162            }
163        }
164        for oldest in evicted {
165            inner.tokens.remove(&oldest);
166        }
167        inner
168            .by_source
169            .entry(source_path.to_string())
170            .or_default()
171            .push_back(token.clone());
172        inner.tokens.insert(
173            token.clone(),
174            SaveTokenRecord {
175                source_path: source_path.to_string(),
176                source_sha256: source_sha256.to_string(),
177                candidate_bytes,
178                created_at: Instant::now(),
179            },
180        );
181        token
182    }
183
184    /// Test-only, non-consuming liveness check: `Ok(())` if `token` exists and has not expired.
185    /// (The caller-less `fetch_prepared_pdf_save_candidate` command that cloned the whole
186    /// candidate out through this lookup was removed; `prepare_pdf_save` already returns the bytes.)
187    #[cfg(test)]
188    fn peek(&self, token: &str) -> Result<(), PdfEditError> {
189        let inner = self.inner.lock().expect("token store mutex poisoned");
190        let record = inner.tokens.get(token).ok_or(PdfEditError::WriteConflict)?;
191        if record.created_at.elapsed() > TOKEN_TTL {
192            return Err(PdfEditError::WriteConflict);
193        }
194        Ok(())
195    }
196
197    /// Single-use consume for [`commit_pdf_save`]: removes and returns the record regardless of
198    /// whether it turns out to be expired (still consumed — see module doc comment on single-use
199    /// semantics). Returns `Err(WriteConflict)` for an unknown/already-consumed/replayed token,
200    /// or an expired one.
201    fn consume(&self, token: &str) -> Result<SaveTokenRecord, PdfEditError> {
202        let mut inner = self.inner.lock().expect("token store mutex poisoned");
203        let Some(record) = inner.tokens.remove(token) else {
204            return Err(PdfEditError::WriteConflict);
205        };
206        if let Some(queue) = inner.by_source.get_mut(&record.source_path) {
207            queue.retain(|t| t != token);
208        }
209        if record.created_at.elapsed() > TOKEN_TTL {
210            return Err(PdfEditError::WriteConflict);
211        }
212        Ok(record)
213    }
214
215    /// The source path `token` was staged against, or `None` for an unknown or already-consumed
216    /// token. An expired token still reports it (consuming it is `consume`'s job): the commit
217    /// needs the destination to serialise on before it touches the token.
218    pub(crate) fn source_path_of(&self, token: &str) -> Option<String> {
219        let inner = self.inner.lock().expect("token store mutex poisoned");
220        inner
221            .tokens
222            .get(token)
223            .map(|record| record.source_path.clone())
224    }
225
226    /// Every source path that has a live token, each once.
227    pub(crate) fn source_paths(&self) -> Vec<String> {
228        let inner = self.inner.lock().expect("token store mutex poisoned");
229        inner
230            .by_source
231            .iter()
232            .filter(|(_, queue)| !queue.is_empty())
233            .map(|(path, _)| path.clone())
234            .collect()
235    }
236
237    /// §10.3 "invalidated after any source revision" (scoped to `source_path` — see module doc
238    /// comment): drops every remaining token for `source_path`, e.g. after a successful commit
239    /// changes that path's on-disk bytes out from under any other outstanding token.
240    pub fn invalidate_source(&self, source_path: &str) {
241        let mut inner = self.inner.lock().expect("token store mutex poisoned");
242        if let Some(queue) = inner.by_source.remove(source_path) {
243            for token in queue {
244                inner.tokens.remove(&token);
245            }
246        }
247    }
248
249    #[cfg(test)]
250    fn live_token_count(&self, source_path: &str) -> usize {
251        let inner = self.inner.lock().expect("token store mutex poisoned");
252        inner
253            .by_source
254            .get(source_path)
255            .map(|q| q.len())
256            .unwrap_or(0)
257    }
258}
259
260/// Mirrors `path_broker.rs`'s `random_token` shape (16 random bytes, lowercase hex) so save
261/// tokens look and behave like every other opaque token this codebase hands to the frontend.
262fn random_save_token() -> String {
263    let mut bytes = [0_u8; 16];
264    getrandom::fill(&mut bytes).expect("OS RNG unavailable");
265    let mut token = String::with_capacity(32);
266    for byte in bytes {
267        use std::fmt::Write;
268        let _ = write!(&mut token, "{byte:02x}");
269    }
270    token
271}
272
273// -------------------------------------------------------------------------------------------
274// §10.1 — Prepare: structural recheck + staging (pure/testable core)
275// -------------------------------------------------------------------------------------------
276
277/// Verifies a just-composed candidate is structurally sound and contains every expected-text
278/// assertion, then stages it in `store` under a fresh token. Separated from the `#[tauri::command]`
279/// wrapper so it is directly unit-testable against hand-built [`lopdf::Document`] fixtures with no
280/// filesystem/broker/IPC involved (mirrors `pdf_inspect.rs`'s `walk_page_graph`/`inspect_pdf_page`
281/// split).
282///
283/// `actual_source_sha256` is the freshly read source bytes' digest; `expected_source_sha256` is
284/// what the frontend's session believed it was editing. A mismatch means the backing file changed
285/// since the edit session started (§10.1 step 2) — rejected before composition is even trusted.
286///
287/// This entry point sees only digests, so it cannot compare the candidate with the bytes it was
288/// composed from. Prefer [`stage_prepared_save_against_base`], which also refuses a candidate in
289/// which a page no longer resolves a font, XObject or graphics state it resolved before.
290pub fn stage_prepared_save(
291    store: &PdfSaveTokenStore,
292    source_path: &str,
293    expected_source_sha256: &str,
294    actual_source_sha256: &str,
295    candidate_bytes: Vec<u8>,
296    affected_pages: Vec<u32>,
297    expected_text: Vec<ExpectedTextCheck>,
298) -> Result<PreparedPdfSaveMeta, PdfEditError> {
299    stage_checked(
300        store,
301        source_path,
302        expected_source_sha256,
303        actual_source_sha256,
304        None,
305        candidate_bytes,
306        affected_pages,
307        expected_text,
308    )
309}
310
311/// [`stage_prepared_save`] plus a comparison with the bytes the candidate was composed from
312/// (`base_bytes`: the source, or the annotation-extended source `select_compose_base` picked).
313/// Besides the structural recheck, every page of the candidate must still resolve every font,
314/// XObject, graphics state, colour space, pattern, shading and property list name that page
315/// resolved in `base_bytes`, to the same object: an edit may add a resource, never hide one
316/// (adding a font to a page whose `/Resources` sat on an ancestor `/Pages` node once did). A
317/// `base_bytes` that does not parse is `VerificationFailed`.
318#[allow(clippy::too_many_arguments)]
319pub fn stage_prepared_save_against_base(
320    store: &PdfSaveTokenStore,
321    source_path: &str,
322    expected_source_sha256: &str,
323    actual_source_sha256: &str,
324    base_bytes: &[u8],
325    candidate_bytes: Vec<u8>,
326    affected_pages: Vec<u32>,
327    expected_text: Vec<ExpectedTextCheck>,
328) -> Result<PreparedPdfSaveMeta, PdfEditError> {
329    stage_checked(
330        store,
331        source_path,
332        expected_source_sha256,
333        actual_source_sha256,
334        Some(base_bytes),
335        candidate_bytes,
336        affected_pages,
337        expected_text,
338    )
339}
340
341#[allow(clippy::too_many_arguments)]
342fn stage_checked(
343    store: &PdfSaveTokenStore,
344    source_path: &str,
345    expected_source_sha256: &str,
346    actual_source_sha256: &str,
347    base_bytes: Option<&[u8]>,
348    candidate_bytes: Vec<u8>,
349    affected_pages: Vec<u32>,
350    expected_text: Vec<ExpectedTextCheck>,
351) -> Result<PreparedPdfSaveMeta, PdfEditError> {
352    if expected_source_sha256 != actual_source_sha256 {
353        return Err(PdfEditError::StaleSource);
354    }
355
356    structurally_verify_candidate_against(&candidate_bytes, &expected_text, base_bytes)?;
357
358    let candidate_sha256 = sha256_hex(&candidate_bytes);
359    let returned_candidate_bytes = candidate_bytes.clone();
360    let token = store.insert(source_path, actual_source_sha256, candidate_bytes);
361
362    Ok(PreparedPdfSaveMeta {
363        token,
364        candidate_sha256,
365        candidate_bytes: returned_candidate_bytes,
366        affected_pages,
367        expected_text,
368    })
369}
370
371/// §10.1 step 7: reopen the candidate independently of whatever composed it, and refuse to hand
372/// out a token for anything that doesn't survive a fresh parse — "never trust your own composer
373/// blindly." See module doc comment for exactly what this checks.
374#[cfg(test)]
375fn structurally_verify_candidate(
376    candidate_bytes: &[u8],
377    expected_text: &[ExpectedTextCheck],
378) -> Result<(), PdfEditError> {
379    structurally_verify_candidate_against(candidate_bytes, expected_text, None)
380}
381
382/// The structural recheck and - when the bytes the candidate was composed from are given - the
383/// check that no page lost a resource name it resolved there.
384fn structurally_verify_candidate_against(
385    candidate_bytes: &[u8],
386    expected_text: &[ExpectedTextCheck],
387    base_bytes: Option<&[u8]>,
388) -> Result<(), PdfEditError> {
389    let doc =
390        crate::inspect::load_pdf(candidate_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
391
392    let pages = doc.get_pages();
393    if pages.is_empty() {
394        return Err(PdfEditError::VerificationFailed);
395    }
396
397    for page_id in pages.values() {
398        let page_obj = doc
399            .get_object(*page_id)
400            .map_err(|_| PdfEditError::VerificationFailed)?;
401        let page_dict = page_obj
402            .as_dict()
403            .map_err(|_| PdfEditError::VerificationFailed)?;
404        if let Ok(lopdf::Object::Reference(id)) = page_dict.get(b"Resources") {
405            doc.get_object(*id)
406                .map_err(|_| PdfEditError::VerificationFailed)?;
407        }
408        if let Ok(contents) = page_dict.get(b"Contents") {
409            // A stream, an array of streams, or an indirect reference to an array of streams.
410            if !matches!(
411                contents,
412                lopdf::Object::Reference(_) | lopdf::Object::Array(_)
413            ) {
414                return Err(PdfEditError::VerificationFailed);
415            }
416            let ids =
417                content_stream_ids(&doc, contents).map_err(|_| PdfEditError::VerificationFailed)?;
418            for id in ids {
419                let obj = doc
420                    .get_object(id)
421                    .map_err(|_| PdfEditError::VerificationFailed)?;
422                obj.as_stream()
423                    .map_err(|_| PdfEditError::VerificationFailed)?;
424            }
425        }
426    }
427
428    if let Some(base_bytes) = base_bytes {
429        let base =
430            crate::inspect::load_pdf(base_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
431        let before = crate::inspect::resource_fingerprint(&base)
432            .map_err(|_| PdfEditError::VerificationFailed)?;
433        let after = crate::inspect::resource_fingerprint(&doc)
434            .map_err(|_| PdfEditError::VerificationFailed)?;
435        if !crate::inspect::resources_preserved(&before, &after) {
436            return Err(PdfEditError::VerificationFailed);
437        }
438    }
439
440    let candidate_sha256 = document_sha256(candidate_bytes);
441    for check in expected_text {
442        let graph = walk_page_graph(&doc, &candidate_sha256, check.page)
443            .map_err(|_| PdfEditError::VerificationFailed)?;
444        let found = graph
445            .islands
446            .iter()
447            .any(|island| island.text.contains(&check.text));
448        if !found {
449            return Err(PdfEditError::VerificationFailed);
450        }
451    }
452
453    Ok(())
454}
455
456/// Picks which bytes `prepare_pdf_save` should compose `source_commands` on top of. The frontend
457/// sends pdf.js's `saveDocument()` output as `annotation_pdf`: a one-byte sentinel `[0]` when there
458/// are no annotation edits, or (per pdf.js's own incremental-save format) the original file bytes
459/// verbatim followed by appended annotation objects/xref when there are. Composing on top of the
460/// annotation bytes (rather than discarding them) is what makes annotation edits survive Save.
461pub fn select_compose_base<'a>(
462    source: &'a [u8],
463    annotation_pdf: &'a [u8],
464) -> Result<&'a [u8], PdfEditError> {
465    if annotation_pdf.len() <= 1 {
466        return Ok(source);
467    }
468    if annotation_pdf.len() > source.len() && annotation_pdf.starts_with(source) {
469        return Ok(annotation_pdf);
470    }
471    Err(PdfEditError::AnnotationSerializationFailed)
472}
473
474/// Derives one [`ExpectedTextCheck`] per page touched by `commands`, from each command's "after"
475/// text — the substring `structurally_verify_candidate` (and the frontend's `verifyPdfCandidate`,
476/// §10.2) must find on that page post-composition.
477pub fn expected_text_checks(commands: &[PdfEditCommand]) -> Vec<ExpectedTextCheck> {
478    effective_pdf_edit_commands(commands)
479        .into_iter()
480        .map(|command| match command {
481            PdfEditCommand::ReplaceSourceText { locator, after, .. } => ExpectedTextCheck {
482                page: locator.page,
483                text: after.text.clone(),
484            },
485            PdfEditCommand::AddText { page, text, .. } => ExpectedTextCheck {
486                page: *page,
487                text: text.clone(),
488            },
489            PdfEditCommand::CorrectOcrLine { page, text, .. } => ExpectedTextCheck {
490                page: *page,
491                text: text.clone(),
492            },
493        })
494        .collect()
495}
496
497// -------------------------------------------------------------------------------------------
498// §10.3 — Commit: token resolution + atomic write (pure/testable core + Tauri command)
499// -------------------------------------------------------------------------------------------
500
501/// Resolves and consumes `token`, returning the bytes to write and the commit result — but
502/// performs no filesystem I/O itself beyond what `read_current_source_for_recheck` does.
503/// `read_current_source_for_recheck` is called only for a same-path Save (§10.3: "Same-path Save
504/// rechecks source SHA immediately before atomic replacement") and must return the *current*
505/// on-disk bytes at `source_path`, compared against the SHA-256 recorded when the token's
506/// candidate was staged ([`SaveTokenRecord::source_sha256`]). Kept filesystem-free otherwise so
507/// token lifecycle (expiry/replay/conflict) is directly unit-testable.
508///
509/// "Same path" here is string equality, which a path alias (`/tmp/./memo.pdf`, a hard link, a case
510/// variant on a case-insensitive volume) defeats. A host that touches the filesystem should call
511/// [`resolve_commit_with`] with a real file-identity comparison; [`crate::commit::commit_pdf_save`]
512/// does.
513pub fn resolve_commit<F>(
514    store: &PdfSaveTokenStore,
515    token: &str,
516    target_path: Option<String>,
517    read_current_source_for_recheck: F,
518) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
519where
520    F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
521{
522    resolve_commit_with(
523        store,
524        token,
525        target_path,
526        |source, target| source == target,
527        read_current_source_for_recheck,
528    )
529}
530
531/// [`resolve_commit`] with the "is the target the source file?" decision supplied by the caller:
532/// `same_file(source_path, target_path)` is called once, after the token is consumed, and the
533/// source recheck runs exactly when it returns `true`. Passing a comparison of filesystem
534/// identities (device and file id, canonical path as the fallback) closes the alias bypass that
535/// plain string equality leaves open.
536pub fn resolve_commit_with<F, S>(
537    store: &PdfSaveTokenStore,
538    token: &str,
539    target_path: Option<String>,
540    same_file: S,
541    read_current_source_for_recheck: F,
542) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
543where
544    F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
545    S: FnOnce(&str, &str) -> bool,
546{
547    let record = store.consume(token)?;
548    let target = target_path.unwrap_or_else(|| record.source_path.clone());
549
550    if same_file(&record.source_path, &target) {
551        let current_bytes = read_current_source_for_recheck(&record.source_path)?;
552        let current_sha256 = document_sha256(&current_bytes);
553        if current_sha256 != record.source_sha256 {
554            return Err(PdfEditError::WriteConflict);
555        }
556    }
557
558    let document_sha256_value = document_sha256(&record.candidate_bytes);
559    let result = PdfCommitResult {
560        target_path: target.clone(),
561        document_sha256: document_sha256_value,
562    };
563    Ok((record.source_path, record.candidate_bytes, result))
564}
565
566// -------------------------------------------------------------------------------------------
567// Tests (§14.1: token/stale/conflict fixtures)
568// -------------------------------------------------------------------------------------------
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    /// One-page PDF whose single `Tj` run is exactly the text
575    /// `structurally_verify_candidate`'s expected-text recheck looks for — the crate's shared
576    /// `build_one_page_pdf_bytes` fixture (base-14 Helvetica/WinAnsiEncoding, verified against real
577    /// lopdf/PDF-spec structure).
578    use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
579
580    fn hello_pdf() -> Vec<u8> {
581        build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET")
582    }
583
584    #[test]
585    fn structural_recheck_accepts_wellformed_candidate_with_matching_text() {
586        let bytes = hello_pdf();
587        let checks = vec![ExpectedTextCheck {
588            page: 1,
589            text: "Hello".to_string(),
590        }];
591        assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
592    }
593
594    #[test]
595    fn structural_recheck_accepts_contents_that_is_an_indirect_array_of_streams() {
596        let doc = crate::test_fixtures::custom_page_pdf(
597            |d| vec![("F1", crate::test_fixtures::helvetica_font(d))],
598            &[b"BT /F1 12 Tf 72 700 Td ", b"(Hello) Tj ET"],
599            true,
600        );
601        let bytes = crate::test_fixtures::pdf_bytes(&doc);
602        let checks = vec![ExpectedTextCheck {
603            page: 1,
604            text: "Hello".to_string(),
605        }];
606        assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
607    }
608
609    #[test]
610    fn structural_recheck_rejects_missing_expected_text() {
611        let bytes = hello_pdf();
612        let checks = vec![ExpectedTextCheck {
613            page: 1,
614            text: "Goodbye".to_string(),
615        }];
616        let err = structurally_verify_candidate(&bytes, &checks).unwrap_err();
617        assert!(matches!(err, PdfEditError::VerificationFailed));
618    }
619
620    #[test]
621    fn structural_recheck_rejects_unparseable_candidate() {
622        let bytes = b"not a pdf at all".to_vec();
623        let err = structurally_verify_candidate(&bytes, &[]).unwrap_err();
624        assert!(matches!(err, PdfEditError::VerificationFailed));
625    }
626
627    #[test]
628    fn stage_prepared_save_rejects_stale_source_before_touching_candidate() {
629        let store = PdfSaveTokenStore::default();
630        let bytes = hello_pdf();
631        let err = stage_prepared_save(
632            &store,
633            "/doc.pdf",
634            "sha-expected",
635            "sha-actual-different",
636            bytes,
637            vec![1],
638            vec![],
639        )
640        .unwrap_err();
641        assert!(matches!(err, PdfEditError::StaleSource));
642        assert_eq!(store.live_token_count("/doc.pdf"), 0);
643    }
644
645    #[test]
646    fn stage_prepared_save_issues_token_for_valid_candidate() {
647        let store = PdfSaveTokenStore::default();
648        let bytes = hello_pdf();
649        let checks = vec![ExpectedTextCheck {
650            page: 1,
651            text: "Hello".to_string(),
652        }];
653        let meta =
654            stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
655                .expect("valid candidate stages");
656        assert_eq!(meta.token.len(), 32);
657        assert_eq!(meta.affected_pages, vec![1]);
658        assert_eq!(store.live_token_count("/doc.pdf"), 1);
659    }
660
661    #[test]
662    fn stage_prepared_save_rejects_verification_failure_without_issuing_token() {
663        let store = PdfSaveTokenStore::default();
664        let bytes = hello_pdf();
665        let checks = vec![ExpectedTextCheck {
666            page: 1,
667            text: "Not Present".to_string(),
668        }];
669        let err = stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
670            .unwrap_err();
671        assert!(matches!(err, PdfEditError::VerificationFailed));
672        assert_eq!(store.live_token_count("/doc.pdf"), 0);
673    }
674
675    #[test]
676    fn store_evicts_oldest_token_beyond_two_per_source() {
677        let store = PdfSaveTokenStore::default();
678        let t1 = store.insert("/doc.pdf", "sha", hello_pdf());
679        let t2 = store.insert("/doc.pdf", "sha", hello_pdf());
680        let t3 = store.insert("/doc.pdf", "sha", hello_pdf());
681
682        assert_eq!(store.live_token_count("/doc.pdf"), 2);
683        assert!(store.peek(&t1).is_err(), "oldest token must be evicted");
684        assert!(store.peek(&t2).is_ok());
685        assert!(store.peek(&t3).is_ok());
686    }
687
688    #[test]
689    fn peek_unknown_token_is_write_conflict() {
690        let store = PdfSaveTokenStore::default();
691        let err = store.peek("does-not-exist").unwrap_err();
692        assert!(matches!(err, PdfEditError::WriteConflict));
693    }
694
695    #[test]
696    fn commit_consumes_token_so_replay_fails() {
697        let store = PdfSaveTokenStore::default();
698        let sha = document_sha256(&hello_pdf());
699        let token = store.insert("/doc.pdf", &sha, hello_pdf());
700
701        let first = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
702        assert!(first.is_ok());
703
704        let replay = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
705        let err = replay.unwrap_err();
706        assert!(matches!(err, PdfEditError::WriteConflict));
707    }
708
709    #[test]
710    fn commit_rejects_expired_token() {
711        let store = PdfSaveTokenStore::default();
712        let token = random_save_token();
713        {
714            let mut inner = store.inner.lock().unwrap();
715            inner
716                .by_source
717                .entry("/doc.pdf".to_string())
718                .or_default()
719                .push_back(token.clone());
720            inner.tokens.insert(
721                token.clone(),
722                SaveTokenRecord {
723                    source_path: "/doc.pdf".to_string(),
724                    source_sha256: document_sha256(&hello_pdf()),
725                    candidate_bytes: hello_pdf(),
726                    created_at: Instant::now() - TOKEN_TTL - Duration::from_secs(1),
727                },
728            );
729        }
730
731        let err = resolve_commit(&store, &token, None, |_| Ok(hello_pdf())).unwrap_err();
732        assert!(matches!(err, PdfEditError::WriteConflict));
733        // Expiry still consumes the token (single-use is unconditional — see module doc comment).
734        assert!(store.peek(&token).is_err());
735    }
736
737    #[test]
738    fn commit_rejects_source_changed_since_prepare_for_same_path_save() {
739        let store = PdfSaveTokenStore::default();
740        let prepare_time_sha = document_sha256(&hello_pdf());
741        let token = store.insert("/doc.pdf", &prepare_time_sha, hello_pdf());
742        let different_current_bytes = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
743
744        let err = resolve_commit(
745            &store,
746            &token,
747            None, // same-path Save
748            |_| Ok(different_current_bytes.clone()),
749        )
750        .unwrap_err();
751        assert!(matches!(err, PdfEditError::WriteConflict));
752    }
753
754    #[test]
755    fn commit_allows_same_path_save_when_source_unchanged() {
756        let store = PdfSaveTokenStore::default();
757        let source_bytes = hello_pdf();
758        let prepare_time_sha = document_sha256(&source_bytes);
759        let token = store.insert("/doc.pdf", &prepare_time_sha, source_bytes.clone());
760
761        let (source_path, bytes, result) =
762            resolve_commit(&store, &token, None, |_| Ok(source_bytes.clone()))
763                .expect("unchanged source commits cleanly");
764        assert_eq!(source_path, "/doc.pdf");
765        assert_eq!(result.target_path, "/doc.pdf");
766        assert_eq!(bytes, source_bytes);
767    }
768
769    #[test]
770    fn commit_save_as_does_not_recheck_source_sha() {
771        // Save As targets a different path than the source; §10.3 only requires the immediate
772        // SHA recheck for a *same-path* Save, so this must succeed even though the closure below
773        // would fail the recheck if it were (wrongly) invoked.
774        let store = PdfSaveTokenStore::default();
775        let sha = document_sha256(&hello_pdf());
776        let token = store.insert("/doc.pdf", &sha, hello_pdf());
777
778        let (source_path, _bytes, result) =
779            resolve_commit(&store, &token, Some("/doc-copy.pdf".to_string()), |_| {
780                panic!("same-path recheck must not run for Save As")
781            })
782            .expect("Save As commits without a same-path recheck");
783        assert_eq!(source_path, "/doc.pdf");
784        assert_eq!(result.target_path, "/doc-copy.pdf");
785    }
786
787    #[test]
788    fn invalidate_source_drops_every_remaining_token_for_that_path() {
789        let store = PdfSaveTokenStore::default();
790        let sha = document_sha256(&hello_pdf());
791        let t1 = store.insert("/doc.pdf", &sha, hello_pdf());
792        assert!(store.peek(&t1).is_ok());
793
794        store.invalidate_source("/doc.pdf");
795
796        assert!(store.peek(&t1).is_err());
797        assert_eq!(store.live_token_count("/doc.pdf"), 0);
798    }
799
800    #[test]
801    fn select_compose_base_uses_source_for_sentinel_annotation_bytes() {
802        let source = hello_pdf();
803        let sentinel = vec![0u8];
804        let base = select_compose_base(&source, &sentinel).expect("sentinel selects source");
805        assert_eq!(base, source.as_slice());
806    }
807
808    #[test]
809    fn select_compose_base_uses_annotation_bytes_when_incremental_update() {
810        let source = hello_pdf();
811        let mut annotation_pdf = source.clone();
812        annotation_pdf.extend_from_slice(b"\n% appended annotation objects + new xref\n");
813        let base =
814            select_compose_base(&source, &annotation_pdf).expect("incremental update selects it");
815        assert_eq!(base, annotation_pdf.as_slice());
816    }
817
818    #[test]
819    fn select_compose_base_rejects_annotation_bytes_not_extending_source() {
820        let source = hello_pdf();
821        let unrelated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Different) Tj ET");
822        let err = select_compose_base(&source, &unrelated).unwrap_err();
823        assert!(matches!(err, PdfEditError::AnnotationSerializationFailed));
824    }
825
826    #[test]
827    fn expected_text_checks_derives_one_entry_per_command() {
828        use crate::model::{
829            PdfColor, PdfPoint, PdfRect, PdfSourceLocator, PdfTextAppearance, PdfTextEditState,
830        };
831
832        let appearance = PdfTextAppearance {
833            font_id: "f".to_string(),
834            family: "F".to_string(),
835            size_pt: 12.0,
836            color: PdfColor {
837                r: 0.0,
838                g: 0.0,
839                b: 0.0,
840                alpha: 1.0,
841            },
842        };
843        let commands = vec![
844            PdfEditCommand::ReplaceSourceText {
845                id: "e1".to_string(),
846                locator: PdfSourceLocator {
847                    document_sha256: "sha".to_string(),
848                    page: 2,
849                    spans: vec![],
850                    island_digest: "digest".to_string(),
851                },
852                before: PdfTextEditState {
853                    text: "old".to_string(),
854                    appearance: appearance.clone(),
855                },
856                after: PdfTextEditState {
857                    text: "new text".to_string(),
858                    appearance: appearance.clone(),
859                },
860                available_bounds: PdfRect {
861                    x: 0.0,
862                    y: 0.0,
863                    width: 10.0,
864                    height: 10.0,
865                },
866            },
867            PdfEditCommand::AddText {
868                id: "a1".to_string(),
869                page: 3,
870                anchor: PdfPoint { x: 1.0, y: 1.0 },
871                text: "added text".to_string(),
872                appearance,
873            },
874        ];
875
876        let checks = expected_text_checks(&commands);
877        assert_eq!(checks.len(), 2);
878        assert_eq!(
879            checks[0],
880            ExpectedTextCheck {
881                page: 2,
882                text: "new text".to_string()
883            }
884        );
885        assert_eq!(
886            checks[1],
887            ExpectedTextCheck {
888                page: 3,
889                text: "added text".to_string()
890            }
891        );
892    }
893
894    #[test]
895    fn expected_text_checks_last_ocr_correction_wins_by_line_id() {
896        use crate::model::{PdfColor, PdfOcrBackgroundProof, PdfRect, PdfTextAppearance};
897        let appearance = PdfTextAppearance {
898            font_id: "f".to_string(),
899            family: "F".to_string(),
900            size_pt: 12.0,
901            color: PdfColor {
902                r: 0.0,
903                g: 0.0,
904                b: 0.0,
905                alpha: 1.0,
906            },
907        };
908        let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
909            id: id.to_string(),
910            page: 4,
911            line_id: "line-1".to_string(),
912            rect: PdfRect {
913                x: 1.0,
914                y: 1.0,
915                width: 100.0,
916                height: 20.0,
917            },
918            text: text.to_string(),
919            appearance: appearance.clone(),
920            cover_color: PdfColor {
921                r: 1.0,
922                g: 1.0,
923                b: 1.0,
924                alpha: 1.0,
925            },
926            background_proof: PdfOcrBackgroundProof::NearUniform,
927        };
928        let checks = expected_text_checks(&[correction("old", "old"), correction("new", "new")]);
929        assert_eq!(
930            checks,
931            vec![ExpectedTextCheck {
932                page: 4,
933                text: "new".to_string()
934            }]
935        );
936    }
937    // -----------------------------------------------------------------------------------
938    // Second Astra read, 2026-10-11: findings 2, 5, 6 (save side)
939    // -----------------------------------------------------------------------------------
940
941    /// The source: `/Resources` (F1, Im1) on the `/Pages` node only.
942    fn inherited_source() -> Vec<u8> {
943        crate::test_fixtures::pdf_bytes(&crate::test_fixtures::inherited_resources_pdf(
944            b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
945        ))
946    }
947
948    /// What the old composer wrote for that page: page-local resources holding only a new font.
949    fn candidate_that_hides_the_inherited_resources(source: &[u8]) -> Vec<u8> {
950        let mut doc = lopdf::Document::load_mem(source).expect("source loads");
951        let page_id = doc.get_pages()[&1];
952        let some_object = *doc.objects.keys().next().expect("an object");
953        if let Ok(lopdf::Object::Dictionary(page)) = doc.get_object_mut(page_id) {
954            page.set(
955                "Resources",
956                lopdf::dictionary! { "Font" => lopdf::dictionary! { "VREdit1" => some_object } },
957            );
958        }
959        crate::test_fixtures::pdf_bytes(&doc)
960    }
961
962    #[test]
963    fn finding2_staging_against_the_base_refuses_a_page_that_lost_its_inherited_resources() {
964        let source = inherited_source();
965        let sha = document_sha256(&source);
966        let hidden = candidate_that_hides_the_inherited_resources(&source);
967        let store = PdfSaveTokenStore::default();
968
969        let err = stage_prepared_save_against_base(
970            &store,
971            "/doc.pdf",
972            &sha,
973            &sha,
974            &source,
975            hidden.clone(),
976            vec![1],
977            vec![],
978        )
979        .unwrap_err();
980        assert!(matches!(err, PdfEditError::VerificationFailed));
981        assert_eq!(store.live_token_count("/doc.pdf"), 0, "no token for it");
982
983        // The digest-only entry point cannot see the source, so it cannot tell: that is why the
984        // composer checks too, and why the against-base entry point exists.
985        stage_prepared_save(&store, "/doc.pdf", &sha, &sha, hidden, vec![1], vec![])
986            .expect("structurally fine, which is all the digest-only path can check");
987    }
988
989    #[test]
990    fn finding2_staging_against_the_base_accepts_a_candidate_that_changes_no_resource() {
991        let source = inherited_source();
992        let sha = document_sha256(&source);
993        let store = PdfSaveTokenStore::default();
994        stage_prepared_save_against_base(
995            &store,
996            "/doc.pdf",
997            &sha,
998            &sha,
999            &source,
1000            source.clone(),
1001            vec![],
1002            vec![],
1003        )
1004        .expect("a candidate identical to its base");
1005    }
1006
1007    #[test]
1008    fn finding2_an_unparseable_base_is_a_verification_failure() {
1009        let store = PdfSaveTokenStore::default();
1010        let err = stage_prepared_save_against_base(
1011            &store,
1012            "/doc.pdf",
1013            "sha",
1014            "sha",
1015            b"not a pdf",
1016            hello_pdf(),
1017            vec![],
1018            vec![],
1019        )
1020        .unwrap_err();
1021        assert!(matches!(err, PdfEditError::VerificationFailed));
1022    }
1023
1024    #[test]
1025    fn finding6_the_recheck_runs_when_the_comparison_says_the_target_is_the_source() {
1026        let store = PdfSaveTokenStore::default();
1027        let sha = document_sha256(&hello_pdf());
1028        let changed = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
1029
1030        // Plain string equality is fooled by an alias: no recheck, so a newer file is replaced.
1031        let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1032        resolve_commit(&store, &token, Some("/tmp/./doc.pdf".to_string()), |_| {
1033            panic!("string comparison skips the recheck for an alias")
1034        })
1035        .expect("the alias gets past string equality");
1036
1037        // The same alias with a file-identity comparison is rechecked and refused.
1038        let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1039        let err = resolve_commit_with(
1040            &store,
1041            &token,
1042            Some("/tmp/./doc.pdf".to_string()),
1043            |source, target| {
1044                assert_eq!((source, target), ("/tmp/doc.pdf", "/tmp/./doc.pdf"));
1045                true
1046            },
1047            |_| Ok(changed.clone()),
1048        )
1049        .unwrap_err();
1050        assert!(matches!(err, PdfEditError::WriteConflict));
1051
1052        // A comparison that says "different file" skips it, as Save As always has.
1053        let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1054        resolve_commit_with(
1055            &store,
1056            &token,
1057            Some("/tmp/other.pdf".to_string()),
1058            |_, _| false,
1059            |_| panic!("a different file is not rechecked"),
1060        )
1061        .expect("Save As");
1062    }
1063
1064    #[test]
1065    fn finding5_the_store_reports_a_tokens_source_and_every_live_source() {
1066        let store = PdfSaveTokenStore::default();
1067        let t1 = store.insert("/a.pdf", "sha", hello_pdf());
1068        store.insert("/b.pdf", "sha", hello_pdf());
1069        assert_eq!(store.source_path_of(&t1).as_deref(), Some("/a.pdf"));
1070        assert_eq!(store.source_path_of("unknown"), None);
1071        let mut sources = store.source_paths();
1072        sources.sort();
1073        assert_eq!(sources, vec!["/a.pdf".to_string(), "/b.pdf".to_string()]);
1074        resolve_commit(&store, &t1, Some("/c.pdf".to_string()), |_| Ok(vec![])).expect("save as");
1075        assert_eq!(store.source_path_of(&t1), None, "consumed");
1076    }
1077}