1use 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
59const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
61
62const MAX_TOKENS_PER_SOURCE: usize = 2;
65
66#[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#[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#[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#[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
110struct SaveTokenRecord {
115 source_path: String,
116 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 by_source: HashMap<String, VecDeque<String>>,
131}
132
133#[derive(Default)]
137pub struct PdfSaveTokenStore {
138 inner: Mutex<TokenStoreInner>,
139}
140
141impl PdfSaveTokenStore {
142 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 #[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 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 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
232fn 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
245pub 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
286fn 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
353pub 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
371pub 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
394pub 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(¤t_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#[cfg(test)]
438mod tests {
439 use super::*;
440
441 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 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, |_| 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 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}