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//! - **Structural recheck (§10.1 step 7: "lopdf reopens candidate, checks page count/resources/
39//!   content streams, & re-inspects expected text")** is implemented as: candidate parses via
40//!   `lopdf::Document::load_mem`, has at least one page, every page's `/Contents` (when present)
41//!   resolves without error, and every [`ExpectedTextCheck`] page has at least one text island
42//!   (via `pdf_inspect::walk_page_graph`, the same walker `inspect_pdf_page` uses) whose text
43//!   contains the expected substring. `ExpectedTextCheck` itself is a new type — §10.1 names the
44//!   field (`expected_text: Vec<ExpectedTextCheck>`) but never defines the type; this file defines
45//!   it as `{ page, text }`, the minimal shape `pdf_compose.rs` can populate per touched page and
46//!   this recheck (and the frontend's `verifyPdfCandidate`, per §10.2) can check against.
47
48use std::collections::{HashMap, VecDeque};
49use std::sync::Mutex;
50use std::time::{Duration, Instant};
51
52use serde::{Deserialize, Serialize};
53
54use crate::inspect::walk_page_graph;
55use crate::model::{
56    document_sha256, effective_pdf_edit_commands, sha256_hex, PdfEditCommand, PdfEditError,
57};
58
59/// A save token is valid for this long after `prepare_pdf_save` issues it (§10.3).
60const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
61
62/// §10.3: "Keep at most two candidates per document session" (scoped to `source_path` — see
63/// module doc comment).
64const MAX_TOKENS_PER_SOURCE: usize = 2;
65
66// -------------------------------------------------------------------------------------------
67// Wire types this file owns (§10.1 names these fields but leaves their shapes to this lane)
68// -------------------------------------------------------------------------------------------
69
70/// One page-level assertion a composed candidate must satisfy before `prepare_pdf_save` will hand
71/// out a token for it: page `page` must contain `text` somewhere in its extracted text. Built from
72/// each [`PdfEditCommand`]'s "after" text, one entry per touched page.
73#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
74#[serde(rename_all = "camelCase")]
75pub struct ExpectedTextCheck {
76    pub page: u32,
77    pub text: String,
78}
79
80/// JSON result returned by `prepare_pdf_save`. Candidate bytes use a numeric array because
81/// WKWebView's raw-response and large-base64 decoding paths are unreliable here.
82#[derive(Clone, Debug, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct PreparedPdfSaveMeta {
85    pub token: String,
86    pub candidate_sha256: String,
87    pub candidate_bytes: Vec<u8>,
88    pub affected_pages: Vec<u32>,
89    pub expected_text: Vec<ExpectedTextCheck>,
90}
91
92/// Result of a successful [`commit_pdf_save`].
93#[derive(Clone, Debug, Serialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PdfCommitResult {
96    pub target_path: String,
97    pub document_sha256: String,
98}
99
100/// JSON request for `prepare_pdf_save`; byte content is base64 rather than a numeric JSON array.
101#[derive(Debug, Clone, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct PreparePdfSaveRequestMeta {
104    pub source_path: String,
105    pub expected_source_sha256: String,
106    pub annotation_pdf_base64: String,
107    pub source_commands: Vec<PdfEditCommand>,
108}
109
110// -------------------------------------------------------------------------------------------
111// Token store (Tauri-managed state; see module doc comment on the missing `AppState`)
112// -------------------------------------------------------------------------------------------
113
114struct SaveTokenRecord {
115    source_path: String,
116    /// Source document SHA-256 at the moment this candidate was staged (i.e. the same value
117    /// `stage_prepared_save`'s caller already verified as `actual_source_sha256`). Stored on the
118    /// record itself so `commit_pdf_save`'s same-path recheck (§10.3) has a prepare-time value to
119    /// compare against without needing a separate session ledger.
120    source_sha256: String,
121    candidate_bytes: Vec<u8>,
122    created_at: Instant,
123}
124
125#[derive(Default)]
126struct TokenStoreInner {
127    tokens: HashMap<String, SaveTokenRecord>,
128    /// Insertion-ordered token ids per source path, oldest first — drives §10.3's two-candidate
129    /// cap.
130    by_source: HashMap<String, VecDeque<String>>,
131}
132
133/// In-memory save-token registry for the prepare/commit handshake (§10.3). One instance is
134/// managed as Tauri state (`app.manage(PdfSaveTokenStore::default())`, wired by whichever lane
135/// owns `lib.rs`) and shared across `prepare_pdf_save` and `commit_pdf_save`.
136#[derive(Default)]
137pub struct PdfSaveTokenStore {
138    inner: Mutex<TokenStoreInner>,
139}
140
141impl PdfSaveTokenStore {
142    /// Registers a newly composed+verified candidate under a fresh token, evicting the oldest
143    /// token for `source_path` if this would exceed [`MAX_TOKENS_PER_SOURCE`]. Returns the token.
144    fn insert(&self, source_path: &str, source_sha256: &str, candidate_bytes: Vec<u8>) -> String {
145        let token = random_save_token();
146        let mut inner = self.inner.lock().expect("token store mutex poisoned");
147
148        let mut evicted = Vec::new();
149        {
150            let queue = inner.by_source.entry(source_path.to_string()).or_default();
151            while queue.len() >= MAX_TOKENS_PER_SOURCE {
152                match queue.pop_front() {
153                    Some(oldest) => evicted.push(oldest),
154                    None => break,
155                }
156            }
157        }
158        for oldest in evicted {
159            inner.tokens.remove(&oldest);
160        }
161        inner
162            .by_source
163            .entry(source_path.to_string())
164            .or_default()
165            .push_back(token.clone());
166        inner.tokens.insert(
167            token.clone(),
168            SaveTokenRecord {
169                source_path: source_path.to_string(),
170                source_sha256: source_sha256.to_string(),
171                candidate_bytes,
172                created_at: Instant::now(),
173            },
174        );
175        token
176    }
177
178    /// Test-only, non-consuming liveness check: `Ok(())` if `token` exists and has not expired.
179    /// (The caller-less `fetch_prepared_pdf_save_candidate` command that cloned the whole
180    /// candidate out through this lookup was removed; `prepare_pdf_save` already returns the bytes.)
181    #[cfg(test)]
182    fn peek(&self, token: &str) -> Result<(), PdfEditError> {
183        let inner = self.inner.lock().expect("token store mutex poisoned");
184        let record = inner.tokens.get(token).ok_or(PdfEditError::WriteConflict)?;
185        if record.created_at.elapsed() > TOKEN_TTL {
186            return Err(PdfEditError::WriteConflict);
187        }
188        Ok(())
189    }
190
191    /// Single-use consume for [`commit_pdf_save`]: removes and returns the record regardless of
192    /// whether it turns out to be expired (still consumed — see module doc comment on single-use
193    /// semantics). Returns `Err(WriteConflict)` for an unknown/already-consumed/replayed token,
194    /// or an expired one.
195    fn consume(&self, token: &str) -> Result<SaveTokenRecord, PdfEditError> {
196        let mut inner = self.inner.lock().expect("token store mutex poisoned");
197        let Some(record) = inner.tokens.remove(token) else {
198            return Err(PdfEditError::WriteConflict);
199        };
200        if let Some(queue) = inner.by_source.get_mut(&record.source_path) {
201            queue.retain(|t| t != token);
202        }
203        if record.created_at.elapsed() > TOKEN_TTL {
204            return Err(PdfEditError::WriteConflict);
205        }
206        Ok(record)
207    }
208
209    /// §10.3 "invalidated after any source revision" (scoped to `source_path` — see module doc
210    /// comment): drops every remaining token for `source_path`, e.g. after a successful commit
211    /// changes that path's on-disk bytes out from under any other outstanding token.
212    pub fn invalidate_source(&self, source_path: &str) {
213        let mut inner = self.inner.lock().expect("token store mutex poisoned");
214        if let Some(queue) = inner.by_source.remove(source_path) {
215            for token in queue {
216                inner.tokens.remove(&token);
217            }
218        }
219    }
220
221    #[cfg(test)]
222    fn live_token_count(&self, source_path: &str) -> usize {
223        let inner = self.inner.lock().expect("token store mutex poisoned");
224        inner
225            .by_source
226            .get(source_path)
227            .map(|q| q.len())
228            .unwrap_or(0)
229    }
230}
231
232/// Mirrors `path_broker.rs`'s `random_token` shape (16 random bytes, lowercase hex) so save
233/// tokens look and behave like every other opaque token this codebase hands to the frontend.
234fn random_save_token() -> String {
235    let mut bytes = [0_u8; 16];
236    getrandom::fill(&mut bytes).expect("OS RNG unavailable");
237    let mut token = String::with_capacity(32);
238    for byte in bytes {
239        use std::fmt::Write;
240        let _ = write!(&mut token, "{byte:02x}");
241    }
242    token
243}
244
245// -------------------------------------------------------------------------------------------
246// §10.1 — Prepare: structural recheck + staging (pure/testable core)
247// -------------------------------------------------------------------------------------------
248
249/// Verifies a just-composed candidate is structurally sound and contains every expected-text
250/// assertion, then stages it in `store` under a fresh token. Separated from the `#[tauri::command]`
251/// wrapper so it is directly unit-testable against hand-built [`lopdf::Document`] fixtures with no
252/// filesystem/broker/IPC involved (mirrors `pdf_inspect.rs`'s `walk_page_graph`/`inspect_pdf_page`
253/// split).
254///
255/// `actual_source_sha256` is the freshly read source bytes' digest; `expected_source_sha256` is
256/// what the frontend's session believed it was editing. A mismatch means the backing file changed
257/// since the edit session started (§10.1 step 2) — rejected before composition is even trusted.
258pub fn stage_prepared_save(
259    store: &PdfSaveTokenStore,
260    source_path: &str,
261    expected_source_sha256: &str,
262    actual_source_sha256: &str,
263    candidate_bytes: Vec<u8>,
264    affected_pages: Vec<u32>,
265    expected_text: Vec<ExpectedTextCheck>,
266) -> Result<PreparedPdfSaveMeta, PdfEditError> {
267    if expected_source_sha256 != actual_source_sha256 {
268        return Err(PdfEditError::StaleSource);
269    }
270
271    structurally_verify_candidate(&candidate_bytes, &expected_text)?;
272
273    let candidate_sha256 = sha256_hex(&candidate_bytes);
274    let returned_candidate_bytes = candidate_bytes.clone();
275    let token = store.insert(source_path, actual_source_sha256, candidate_bytes);
276
277    Ok(PreparedPdfSaveMeta {
278        token,
279        candidate_sha256,
280        candidate_bytes: returned_candidate_bytes,
281        affected_pages,
282        expected_text,
283    })
284}
285
286/// §10.1 step 7: reopen the candidate independently of whatever composed it, and refuse to hand
287/// out a token for anything that doesn't survive a fresh parse — "never trust your own composer
288/// blindly." See module doc comment for exactly what this checks.
289fn structurally_verify_candidate(
290    candidate_bytes: &[u8],
291    expected_text: &[ExpectedTextCheck],
292) -> Result<(), PdfEditError> {
293    let doc =
294        lopdf::Document::load_mem(candidate_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
295
296    let pages = doc.get_pages();
297    if pages.is_empty() {
298        return Err(PdfEditError::VerificationFailed);
299    }
300
301    for page_id in pages.values() {
302        let page_obj = doc
303            .get_object(*page_id)
304            .map_err(|_| PdfEditError::VerificationFailed)?;
305        let page_dict = page_obj
306            .as_dict()
307            .map_err(|_| PdfEditError::VerificationFailed)?;
308        if let Ok(lopdf::Object::Reference(id)) = page_dict.get(b"Resources") {
309            doc.get_object(*id)
310                .map_err(|_| PdfEditError::VerificationFailed)?;
311        }
312        if let Ok(contents) = page_dict.get(b"Contents") {
313            match contents {
314                lopdf::Object::Reference(id) => {
315                    let obj = doc
316                        .get_object(*id)
317                        .map_err(|_| PdfEditError::VerificationFailed)?;
318                    obj.as_stream()
319                        .map_err(|_| PdfEditError::VerificationFailed)?;
320                }
321                lopdf::Object::Array(items) => {
322                    for item in items {
323                        if let lopdf::Object::Reference(id) = item {
324                            let obj = doc
325                                .get_object(*id)
326                                .map_err(|_| PdfEditError::VerificationFailed)?;
327                            obj.as_stream()
328                                .map_err(|_| PdfEditError::VerificationFailed)?;
329                        }
330                    }
331                }
332                _ => return Err(PdfEditError::VerificationFailed),
333            }
334        }
335    }
336
337    let candidate_sha256 = document_sha256(candidate_bytes);
338    for check in expected_text {
339        let graph = walk_page_graph(&doc, &candidate_sha256, check.page)
340            .map_err(|_| PdfEditError::VerificationFailed)?;
341        let found = graph
342            .islands
343            .iter()
344            .any(|island| island.text.contains(&check.text));
345        if !found {
346            return Err(PdfEditError::VerificationFailed);
347        }
348    }
349
350    Ok(())
351}
352
353/// Picks which bytes `prepare_pdf_save` should compose `source_commands` on top of. The frontend
354/// sends pdf.js's `saveDocument()` output as `annotation_pdf`: a one-byte sentinel `[0]` when there
355/// are no annotation edits, or (per pdf.js's own incremental-save format) the original file bytes
356/// verbatim followed by appended annotation objects/xref when there are. Composing on top of the
357/// annotation bytes (rather than discarding them) is what makes annotation edits survive Save.
358pub fn select_compose_base<'a>(
359    source: &'a [u8],
360    annotation_pdf: &'a [u8],
361) -> Result<&'a [u8], PdfEditError> {
362    if annotation_pdf.len() <= 1 {
363        return Ok(source);
364    }
365    if annotation_pdf.len() > source.len() && annotation_pdf.starts_with(source) {
366        return Ok(annotation_pdf);
367    }
368    Err(PdfEditError::AnnotationSerializationFailed)
369}
370
371/// Derives one [`ExpectedTextCheck`] per page touched by `commands`, from each command's "after"
372/// text — the substring `structurally_verify_candidate` (and the frontend's `verifyPdfCandidate`,
373/// §10.2) must find on that page post-composition.
374pub fn expected_text_checks(commands: &[PdfEditCommand]) -> Vec<ExpectedTextCheck> {
375    effective_pdf_edit_commands(commands)
376        .into_iter()
377        .map(|command| match command {
378            PdfEditCommand::ReplaceSourceText { locator, after, .. } => ExpectedTextCheck {
379                page: locator.page,
380                text: after.text.clone(),
381            },
382            PdfEditCommand::AddText { page, text, .. } => ExpectedTextCheck {
383                page: *page,
384                text: text.clone(),
385            },
386            PdfEditCommand::CorrectOcrLine { page, text, .. } => ExpectedTextCheck {
387                page: *page,
388                text: text.clone(),
389            },
390        })
391        .collect()
392}
393
394// -------------------------------------------------------------------------------------------
395// §10.3 — Commit: token resolution + atomic write (pure/testable core + Tauri command)
396// -------------------------------------------------------------------------------------------
397
398/// Resolves and consumes `token`, returning the bytes to write and the commit result — but
399/// performs no filesystem I/O itself beyond what `read_current_source_for_recheck` does.
400/// `read_current_source_for_recheck` is called only for a same-path Save (§10.3: "Same-path Save
401/// rechecks source SHA immediately before atomic replacement") and must return the *current*
402/// on-disk bytes at `source_path`, compared against the SHA-256 recorded when the token's
403/// candidate was staged ([`SaveTokenRecord::source_sha256`]). Kept filesystem-free otherwise so
404/// token lifecycle (expiry/replay/conflict) is directly unit-testable.
405pub fn resolve_commit<F>(
406    store: &PdfSaveTokenStore,
407    token: &str,
408    target_path: Option<String>,
409    read_current_source_for_recheck: F,
410) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
411where
412    F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
413{
414    let record = store.consume(token)?;
415    let target = target_path.unwrap_or_else(|| record.source_path.clone());
416
417    if target == record.source_path {
418        let current_bytes = read_current_source_for_recheck(&record.source_path)?;
419        let current_sha256 = document_sha256(&current_bytes);
420        if current_sha256 != record.source_sha256 {
421            return Err(PdfEditError::WriteConflict);
422        }
423    }
424
425    let document_sha256_value = document_sha256(&record.candidate_bytes);
426    let result = PdfCommitResult {
427        target_path: target.clone(),
428        document_sha256: document_sha256_value,
429    };
430    Ok((record.source_path, record.candidate_bytes, result))
431}
432
433// -------------------------------------------------------------------------------------------
434// Tests (§14.1: token/stale/conflict fixtures)
435// -------------------------------------------------------------------------------------------
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    /// One-page PDF whose single `Tj` run is exactly the text
442    /// `structurally_verify_candidate`'s expected-text recheck looks for — the crate's shared
443    /// `build_one_page_pdf_bytes` fixture (base-14 Helvetica/WinAnsiEncoding, verified against real
444    /// lopdf/PDF-spec structure).
445    use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
446
447    fn hello_pdf() -> Vec<u8> {
448        build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET")
449    }
450
451    #[test]
452    fn structural_recheck_accepts_wellformed_candidate_with_matching_text() {
453        let bytes = hello_pdf();
454        let checks = vec![ExpectedTextCheck {
455            page: 1,
456            text: "Hello".to_string(),
457        }];
458        assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
459    }
460
461    #[test]
462    fn structural_recheck_rejects_missing_expected_text() {
463        let bytes = hello_pdf();
464        let checks = vec![ExpectedTextCheck {
465            page: 1,
466            text: "Goodbye".to_string(),
467        }];
468        let err = structurally_verify_candidate(&bytes, &checks).unwrap_err();
469        assert!(matches!(err, PdfEditError::VerificationFailed));
470    }
471
472    #[test]
473    fn structural_recheck_rejects_unparseable_candidate() {
474        let bytes = b"not a pdf at all".to_vec();
475        let err = structurally_verify_candidate(&bytes, &[]).unwrap_err();
476        assert!(matches!(err, PdfEditError::VerificationFailed));
477    }
478
479    #[test]
480    fn stage_prepared_save_rejects_stale_source_before_touching_candidate() {
481        let store = PdfSaveTokenStore::default();
482        let bytes = hello_pdf();
483        let err = stage_prepared_save(
484            &store,
485            "/doc.pdf",
486            "sha-expected",
487            "sha-actual-different",
488            bytes,
489            vec![1],
490            vec![],
491        )
492        .unwrap_err();
493        assert!(matches!(err, PdfEditError::StaleSource));
494        assert_eq!(store.live_token_count("/doc.pdf"), 0);
495    }
496
497    #[test]
498    fn stage_prepared_save_issues_token_for_valid_candidate() {
499        let store = PdfSaveTokenStore::default();
500        let bytes = hello_pdf();
501        let checks = vec![ExpectedTextCheck {
502            page: 1,
503            text: "Hello".to_string(),
504        }];
505        let meta =
506            stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
507                .expect("valid candidate stages");
508        assert_eq!(meta.token.len(), 32);
509        assert_eq!(meta.affected_pages, vec![1]);
510        assert_eq!(store.live_token_count("/doc.pdf"), 1);
511    }
512
513    #[test]
514    fn stage_prepared_save_rejects_verification_failure_without_issuing_token() {
515        let store = PdfSaveTokenStore::default();
516        let bytes = hello_pdf();
517        let checks = vec![ExpectedTextCheck {
518            page: 1,
519            text: "Not Present".to_string(),
520        }];
521        let err = stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
522            .unwrap_err();
523        assert!(matches!(err, PdfEditError::VerificationFailed));
524        assert_eq!(store.live_token_count("/doc.pdf"), 0);
525    }
526
527    #[test]
528    fn store_evicts_oldest_token_beyond_two_per_source() {
529        let store = PdfSaveTokenStore::default();
530        let t1 = store.insert("/doc.pdf", "sha", hello_pdf());
531        let t2 = store.insert("/doc.pdf", "sha", hello_pdf());
532        let t3 = store.insert("/doc.pdf", "sha", hello_pdf());
533
534        assert_eq!(store.live_token_count("/doc.pdf"), 2);
535        assert!(store.peek(&t1).is_err(), "oldest token must be evicted");
536        assert!(store.peek(&t2).is_ok());
537        assert!(store.peek(&t3).is_ok());
538    }
539
540    #[test]
541    fn peek_unknown_token_is_write_conflict() {
542        let store = PdfSaveTokenStore::default();
543        let err = store.peek("does-not-exist").unwrap_err();
544        assert!(matches!(err, PdfEditError::WriteConflict));
545    }
546
547    #[test]
548    fn commit_consumes_token_so_replay_fails() {
549        let store = PdfSaveTokenStore::default();
550        let sha = document_sha256(&hello_pdf());
551        let token = store.insert("/doc.pdf", &sha, hello_pdf());
552
553        let first = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
554        assert!(first.is_ok());
555
556        let replay = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
557        let err = replay.unwrap_err();
558        assert!(matches!(err, PdfEditError::WriteConflict));
559    }
560
561    #[test]
562    fn commit_rejects_expired_token() {
563        let store = PdfSaveTokenStore::default();
564        let token = random_save_token();
565        {
566            let mut inner = store.inner.lock().unwrap();
567            inner
568                .by_source
569                .entry("/doc.pdf".to_string())
570                .or_default()
571                .push_back(token.clone());
572            inner.tokens.insert(
573                token.clone(),
574                SaveTokenRecord {
575                    source_path: "/doc.pdf".to_string(),
576                    source_sha256: document_sha256(&hello_pdf()),
577                    candidate_bytes: hello_pdf(),
578                    created_at: Instant::now() - TOKEN_TTL - Duration::from_secs(1),
579                },
580            );
581        }
582
583        let err = resolve_commit(&store, &token, None, |_| Ok(hello_pdf())).unwrap_err();
584        assert!(matches!(err, PdfEditError::WriteConflict));
585        // Expiry still consumes the token (single-use is unconditional — see module doc comment).
586        assert!(store.peek(&token).is_err());
587    }
588
589    #[test]
590    fn commit_rejects_source_changed_since_prepare_for_same_path_save() {
591        let store = PdfSaveTokenStore::default();
592        let prepare_time_sha = document_sha256(&hello_pdf());
593        let token = store.insert("/doc.pdf", &prepare_time_sha, hello_pdf());
594        let different_current_bytes = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
595
596        let err = resolve_commit(
597            &store,
598            &token,
599            None, // same-path Save
600            |_| Ok(different_current_bytes.clone()),
601        )
602        .unwrap_err();
603        assert!(matches!(err, PdfEditError::WriteConflict));
604    }
605
606    #[test]
607    fn commit_allows_same_path_save_when_source_unchanged() {
608        let store = PdfSaveTokenStore::default();
609        let source_bytes = hello_pdf();
610        let prepare_time_sha = document_sha256(&source_bytes);
611        let token = store.insert("/doc.pdf", &prepare_time_sha, source_bytes.clone());
612
613        let (source_path, bytes, result) =
614            resolve_commit(&store, &token, None, |_| Ok(source_bytes.clone()))
615                .expect("unchanged source commits cleanly");
616        assert_eq!(source_path, "/doc.pdf");
617        assert_eq!(result.target_path, "/doc.pdf");
618        assert_eq!(bytes, source_bytes);
619    }
620
621    #[test]
622    fn commit_save_as_does_not_recheck_source_sha() {
623        // Save As targets a different path than the source; §10.3 only requires the immediate
624        // SHA recheck for a *same-path* Save, so this must succeed even though the closure below
625        // would fail the recheck if it were (wrongly) invoked.
626        let store = PdfSaveTokenStore::default();
627        let sha = document_sha256(&hello_pdf());
628        let token = store.insert("/doc.pdf", &sha, hello_pdf());
629
630        let (source_path, _bytes, result) =
631            resolve_commit(&store, &token, Some("/doc-copy.pdf".to_string()), |_| {
632                panic!("same-path recheck must not run for Save As")
633            })
634            .expect("Save As commits without a same-path recheck");
635        assert_eq!(source_path, "/doc.pdf");
636        assert_eq!(result.target_path, "/doc-copy.pdf");
637    }
638
639    #[test]
640    fn invalidate_source_drops_every_remaining_token_for_that_path() {
641        let store = PdfSaveTokenStore::default();
642        let sha = document_sha256(&hello_pdf());
643        let t1 = store.insert("/doc.pdf", &sha, hello_pdf());
644        assert!(store.peek(&t1).is_ok());
645
646        store.invalidate_source("/doc.pdf");
647
648        assert!(store.peek(&t1).is_err());
649        assert_eq!(store.live_token_count("/doc.pdf"), 0);
650    }
651
652    #[test]
653    fn select_compose_base_uses_source_for_sentinel_annotation_bytes() {
654        let source = hello_pdf();
655        let sentinel = vec![0u8];
656        let base = select_compose_base(&source, &sentinel).expect("sentinel selects source");
657        assert_eq!(base, source.as_slice());
658    }
659
660    #[test]
661    fn select_compose_base_uses_annotation_bytes_when_incremental_update() {
662        let source = hello_pdf();
663        let mut annotation_pdf = source.clone();
664        annotation_pdf.extend_from_slice(b"\n% appended annotation objects + new xref\n");
665        let base =
666            select_compose_base(&source, &annotation_pdf).expect("incremental update selects it");
667        assert_eq!(base, annotation_pdf.as_slice());
668    }
669
670    #[test]
671    fn select_compose_base_rejects_annotation_bytes_not_extending_source() {
672        let source = hello_pdf();
673        let unrelated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Different) Tj ET");
674        let err = select_compose_base(&source, &unrelated).unwrap_err();
675        assert!(matches!(err, PdfEditError::AnnotationSerializationFailed));
676    }
677
678    #[test]
679    fn expected_text_checks_derives_one_entry_per_command() {
680        use crate::model::{
681            PdfColor, PdfPoint, PdfRect, PdfSourceLocator, PdfTextAppearance, PdfTextEditState,
682        };
683
684        let appearance = PdfTextAppearance {
685            font_id: "f".to_string(),
686            family: "F".to_string(),
687            size_pt: 12.0,
688            color: PdfColor {
689                r: 0.0,
690                g: 0.0,
691                b: 0.0,
692                alpha: 1.0,
693            },
694        };
695        let commands = vec![
696            PdfEditCommand::ReplaceSourceText {
697                id: "e1".to_string(),
698                locator: PdfSourceLocator {
699                    document_sha256: "sha".to_string(),
700                    page: 2,
701                    spans: vec![],
702                    island_digest: "digest".to_string(),
703                },
704                before: PdfTextEditState {
705                    text: "old".to_string(),
706                    appearance: appearance.clone(),
707                },
708                after: PdfTextEditState {
709                    text: "new text".to_string(),
710                    appearance: appearance.clone(),
711                },
712                available_bounds: PdfRect {
713                    x: 0.0,
714                    y: 0.0,
715                    width: 10.0,
716                    height: 10.0,
717                },
718            },
719            PdfEditCommand::AddText {
720                id: "a1".to_string(),
721                page: 3,
722                anchor: PdfPoint { x: 1.0, y: 1.0 },
723                text: "added text".to_string(),
724                appearance,
725            },
726        ];
727
728        let checks = expected_text_checks(&commands);
729        assert_eq!(checks.len(), 2);
730        assert_eq!(
731            checks[0],
732            ExpectedTextCheck {
733                page: 2,
734                text: "new text".to_string()
735            }
736        );
737        assert_eq!(
738            checks[1],
739            ExpectedTextCheck {
740                page: 3,
741                text: "added text".to_string()
742            }
743        );
744    }
745
746    #[test]
747    fn expected_text_checks_last_ocr_correction_wins_by_line_id() {
748        use crate::model::{PdfColor, PdfOcrBackgroundProof, PdfRect, PdfTextAppearance};
749        let appearance = PdfTextAppearance {
750            font_id: "f".to_string(),
751            family: "F".to_string(),
752            size_pt: 12.0,
753            color: PdfColor {
754                r: 0.0,
755                g: 0.0,
756                b: 0.0,
757                alpha: 1.0,
758            },
759        };
760        let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
761            id: id.to_string(),
762            page: 4,
763            line_id: "line-1".to_string(),
764            rect: PdfRect {
765                x: 1.0,
766                y: 1.0,
767                width: 100.0,
768                height: 20.0,
769            },
770            text: text.to_string(),
771            appearance: appearance.clone(),
772            cover_color: PdfColor {
773                r: 1.0,
774                g: 1.0,
775                b: 1.0,
776                alpha: 1.0,
777            },
778            background_proof: PdfOcrBackgroundProof::NearUniform,
779        };
780        let checks = expected_text_checks(&[correction("old", "old"), correction("new", "new")]);
781        assert_eq!(
782            checks,
783            vec![ExpectedTextCheck {
784                page: 4,
785                text: "new".to_string()
786            }]
787        );
788    }
789}