use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::inspect::{content_stream_ids, walk_page_graph};
use crate::model::{
document_sha256, effective_pdf_edit_commands, sha256_hex, PdfEditCommand, PdfEditError,
};
const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
const MAX_TOKENS_PER_SOURCE: usize = 2;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExpectedTextCheck {
pub page: u32,
pub text: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreparedPdfSaveMeta {
pub token: String,
pub candidate_sha256: String,
pub candidate_bytes: Vec<u8>,
pub affected_pages: Vec<u32>,
pub expected_text: Vec<ExpectedTextCheck>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfCommitResult {
pub target_path: String,
pub document_sha256: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreparePdfSaveRequestMeta {
pub source_path: String,
pub expected_source_sha256: String,
pub annotation_pdf_base64: String,
pub source_commands: Vec<PdfEditCommand>,
}
struct SaveTokenRecord {
source_path: String,
source_sha256: String,
candidate_bytes: Vec<u8>,
created_at: Instant,
}
#[derive(Default)]
struct TokenStoreInner {
tokens: HashMap<String, SaveTokenRecord>,
by_source: HashMap<String, VecDeque<String>>,
}
#[derive(Default)]
pub struct PdfSaveTokenStore {
inner: Mutex<TokenStoreInner>,
}
impl PdfSaveTokenStore {
fn insert(&self, source_path: &str, source_sha256: &str, candidate_bytes: Vec<u8>) -> String {
let token = random_save_token();
let mut inner = self.inner.lock().expect("token store mutex poisoned");
let mut evicted = Vec::new();
{
let queue = inner.by_source.entry(source_path.to_string()).or_default();
while queue.len() >= MAX_TOKENS_PER_SOURCE {
match queue.pop_front() {
Some(oldest) => evicted.push(oldest),
None => break,
}
}
}
for oldest in evicted {
inner.tokens.remove(&oldest);
}
inner
.by_source
.entry(source_path.to_string())
.or_default()
.push_back(token.clone());
inner.tokens.insert(
token.clone(),
SaveTokenRecord {
source_path: source_path.to_string(),
source_sha256: source_sha256.to_string(),
candidate_bytes,
created_at: Instant::now(),
},
);
token
}
#[cfg(test)]
fn peek(&self, token: &str) -> Result<(), PdfEditError> {
let inner = self.inner.lock().expect("token store mutex poisoned");
let record = inner.tokens.get(token).ok_or(PdfEditError::WriteConflict)?;
if record.created_at.elapsed() > TOKEN_TTL {
return Err(PdfEditError::WriteConflict);
}
Ok(())
}
fn consume(&self, token: &str) -> Result<SaveTokenRecord, PdfEditError> {
let mut inner = self.inner.lock().expect("token store mutex poisoned");
let Some(record) = inner.tokens.remove(token) else {
return Err(PdfEditError::WriteConflict);
};
if let Some(queue) = inner.by_source.get_mut(&record.source_path) {
queue.retain(|t| t != token);
}
if record.created_at.elapsed() > TOKEN_TTL {
return Err(PdfEditError::WriteConflict);
}
Ok(record)
}
pub(crate) fn source_path_of(&self, token: &str) -> Option<String> {
let inner = self.inner.lock().expect("token store mutex poisoned");
inner
.tokens
.get(token)
.map(|record| record.source_path.clone())
}
pub(crate) fn source_paths(&self) -> Vec<String> {
let inner = self.inner.lock().expect("token store mutex poisoned");
inner
.by_source
.iter()
.filter(|(_, queue)| !queue.is_empty())
.map(|(path, _)| path.clone())
.collect()
}
pub fn invalidate_source(&self, source_path: &str) {
let mut inner = self.inner.lock().expect("token store mutex poisoned");
if let Some(queue) = inner.by_source.remove(source_path) {
for token in queue {
inner.tokens.remove(&token);
}
}
}
#[cfg(test)]
fn live_token_count(&self, source_path: &str) -> usize {
let inner = self.inner.lock().expect("token store mutex poisoned");
inner
.by_source
.get(source_path)
.map(|q| q.len())
.unwrap_or(0)
}
}
fn random_save_token() -> String {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes).expect("OS RNG unavailable");
let mut token = String::with_capacity(32);
for byte in bytes {
use std::fmt::Write;
let _ = write!(&mut token, "{byte:02x}");
}
token
}
pub fn stage_prepared_save(
store: &PdfSaveTokenStore,
source_path: &str,
expected_source_sha256: &str,
actual_source_sha256: &str,
candidate_bytes: Vec<u8>,
affected_pages: Vec<u32>,
expected_text: Vec<ExpectedTextCheck>,
) -> Result<PreparedPdfSaveMeta, PdfEditError> {
stage_checked(
store,
source_path,
expected_source_sha256,
actual_source_sha256,
None,
candidate_bytes,
affected_pages,
expected_text,
)
}
#[allow(clippy::too_many_arguments)]
pub fn stage_prepared_save_against_base(
store: &PdfSaveTokenStore,
source_path: &str,
expected_source_sha256: &str,
actual_source_sha256: &str,
base_bytes: &[u8],
candidate_bytes: Vec<u8>,
affected_pages: Vec<u32>,
expected_text: Vec<ExpectedTextCheck>,
) -> Result<PreparedPdfSaveMeta, PdfEditError> {
stage_checked(
store,
source_path,
expected_source_sha256,
actual_source_sha256,
Some(base_bytes),
candidate_bytes,
affected_pages,
expected_text,
)
}
#[allow(clippy::too_many_arguments)]
fn stage_checked(
store: &PdfSaveTokenStore,
source_path: &str,
expected_source_sha256: &str,
actual_source_sha256: &str,
base_bytes: Option<&[u8]>,
candidate_bytes: Vec<u8>,
affected_pages: Vec<u32>,
expected_text: Vec<ExpectedTextCheck>,
) -> Result<PreparedPdfSaveMeta, PdfEditError> {
if expected_source_sha256 != actual_source_sha256 {
return Err(PdfEditError::StaleSource);
}
structurally_verify_candidate_against(&candidate_bytes, &expected_text, base_bytes)?;
let candidate_sha256 = sha256_hex(&candidate_bytes);
let returned_candidate_bytes = candidate_bytes.clone();
let token = store.insert(source_path, actual_source_sha256, candidate_bytes);
Ok(PreparedPdfSaveMeta {
token,
candidate_sha256,
candidate_bytes: returned_candidate_bytes,
affected_pages,
expected_text,
})
}
#[cfg(test)]
fn structurally_verify_candidate(
candidate_bytes: &[u8],
expected_text: &[ExpectedTextCheck],
) -> Result<(), PdfEditError> {
structurally_verify_candidate_against(candidate_bytes, expected_text, None)
}
fn structurally_verify_candidate_against(
candidate_bytes: &[u8],
expected_text: &[ExpectedTextCheck],
base_bytes: Option<&[u8]>,
) -> Result<(), PdfEditError> {
let doc =
crate::inspect::load_pdf(candidate_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
let pages = doc.get_pages();
if pages.is_empty() {
return Err(PdfEditError::VerificationFailed);
}
for page_id in pages.values() {
let page_obj = doc
.get_object(*page_id)
.map_err(|_| PdfEditError::VerificationFailed)?;
let page_dict = page_obj
.as_dict()
.map_err(|_| PdfEditError::VerificationFailed)?;
if let Ok(lopdf::Object::Reference(id)) = page_dict.get(b"Resources") {
doc.get_object(*id)
.map_err(|_| PdfEditError::VerificationFailed)?;
}
if let Ok(contents) = page_dict.get(b"Contents") {
if !matches!(
contents,
lopdf::Object::Reference(_) | lopdf::Object::Array(_)
) {
return Err(PdfEditError::VerificationFailed);
}
let ids =
content_stream_ids(&doc, contents).map_err(|_| PdfEditError::VerificationFailed)?;
for id in ids {
let obj = doc
.get_object(id)
.map_err(|_| PdfEditError::VerificationFailed)?;
obj.as_stream()
.map_err(|_| PdfEditError::VerificationFailed)?;
}
}
}
if let Some(base_bytes) = base_bytes {
let base =
crate::inspect::load_pdf(base_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
let before = crate::inspect::resource_fingerprint(&base)
.map_err(|_| PdfEditError::VerificationFailed)?;
let after = crate::inspect::resource_fingerprint(&doc)
.map_err(|_| PdfEditError::VerificationFailed)?;
if !crate::inspect::resources_preserved(&before, &after) {
return Err(PdfEditError::VerificationFailed);
}
}
let candidate_sha256 = document_sha256(candidate_bytes);
for check in expected_text {
let graph = walk_page_graph(&doc, &candidate_sha256, check.page)
.map_err(|_| PdfEditError::VerificationFailed)?;
let found = graph
.islands
.iter()
.any(|island| island.text.contains(&check.text));
if !found {
return Err(PdfEditError::VerificationFailed);
}
}
Ok(())
}
pub fn select_compose_base<'a>(
source: &'a [u8],
annotation_pdf: &'a [u8],
) -> Result<&'a [u8], PdfEditError> {
if annotation_pdf.len() <= 1 {
return Ok(source);
}
if annotation_pdf.len() > source.len() && annotation_pdf.starts_with(source) {
return Ok(annotation_pdf);
}
Err(PdfEditError::AnnotationSerializationFailed)
}
pub fn expected_text_checks(commands: &[PdfEditCommand]) -> Vec<ExpectedTextCheck> {
effective_pdf_edit_commands(commands)
.into_iter()
.map(|command| match command {
PdfEditCommand::ReplaceSourceText { locator, after, .. } => ExpectedTextCheck {
page: locator.page,
text: after.text.clone(),
},
PdfEditCommand::AddText { page, text, .. } => ExpectedTextCheck {
page: *page,
text: text.clone(),
},
PdfEditCommand::CorrectOcrLine { page, text, .. } => ExpectedTextCheck {
page: *page,
text: text.clone(),
},
})
.collect()
}
pub fn resolve_commit<F>(
store: &PdfSaveTokenStore,
token: &str,
target_path: Option<String>,
read_current_source_for_recheck: F,
) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
where
F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
{
resolve_commit_with(
store,
token,
target_path,
|source, target| source == target,
read_current_source_for_recheck,
)
}
pub fn resolve_commit_with<F, S>(
store: &PdfSaveTokenStore,
token: &str,
target_path: Option<String>,
same_file: S,
read_current_source_for_recheck: F,
) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
where
F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
S: FnOnce(&str, &str) -> bool,
{
let record = store.consume(token)?;
let target = target_path.unwrap_or_else(|| record.source_path.clone());
if same_file(&record.source_path, &target) {
let current_bytes = read_current_source_for_recheck(&record.source_path)?;
let current_sha256 = document_sha256(¤t_bytes);
if current_sha256 != record.source_sha256 {
return Err(PdfEditError::WriteConflict);
}
}
let document_sha256_value = document_sha256(&record.candidate_bytes);
let result = PdfCommitResult {
target_path: target.clone(),
document_sha256: document_sha256_value,
};
Ok((record.source_path, record.candidate_bytes, result))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
fn hello_pdf() -> Vec<u8> {
build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET")
}
#[test]
fn structural_recheck_accepts_wellformed_candidate_with_matching_text() {
let bytes = hello_pdf();
let checks = vec![ExpectedTextCheck {
page: 1,
text: "Hello".to_string(),
}];
assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
}
#[test]
fn structural_recheck_accepts_contents_that_is_an_indirect_array_of_streams() {
let doc = crate::test_fixtures::custom_page_pdf(
|d| vec![("F1", crate::test_fixtures::helvetica_font(d))],
&[b"BT /F1 12 Tf 72 700 Td ", b"(Hello) Tj ET"],
true,
);
let bytes = crate::test_fixtures::pdf_bytes(&doc);
let checks = vec![ExpectedTextCheck {
page: 1,
text: "Hello".to_string(),
}];
assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
}
#[test]
fn structural_recheck_rejects_missing_expected_text() {
let bytes = hello_pdf();
let checks = vec![ExpectedTextCheck {
page: 1,
text: "Goodbye".to_string(),
}];
let err = structurally_verify_candidate(&bytes, &checks).unwrap_err();
assert!(matches!(err, PdfEditError::VerificationFailed));
}
#[test]
fn structural_recheck_rejects_unparseable_candidate() {
let bytes = b"not a pdf at all".to_vec();
let err = structurally_verify_candidate(&bytes, &[]).unwrap_err();
assert!(matches!(err, PdfEditError::VerificationFailed));
}
#[test]
fn stage_prepared_save_rejects_stale_source_before_touching_candidate() {
let store = PdfSaveTokenStore::default();
let bytes = hello_pdf();
let err = stage_prepared_save(
&store,
"/doc.pdf",
"sha-expected",
"sha-actual-different",
bytes,
vec![1],
vec![],
)
.unwrap_err();
assert!(matches!(err, PdfEditError::StaleSource));
assert_eq!(store.live_token_count("/doc.pdf"), 0);
}
#[test]
fn stage_prepared_save_issues_token_for_valid_candidate() {
let store = PdfSaveTokenStore::default();
let bytes = hello_pdf();
let checks = vec![ExpectedTextCheck {
page: 1,
text: "Hello".to_string(),
}];
let meta =
stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
.expect("valid candidate stages");
assert_eq!(meta.token.len(), 32);
assert_eq!(meta.affected_pages, vec![1]);
assert_eq!(store.live_token_count("/doc.pdf"), 1);
}
#[test]
fn stage_prepared_save_rejects_verification_failure_without_issuing_token() {
let store = PdfSaveTokenStore::default();
let bytes = hello_pdf();
let checks = vec![ExpectedTextCheck {
page: 1,
text: "Not Present".to_string(),
}];
let err = stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
.unwrap_err();
assert!(matches!(err, PdfEditError::VerificationFailed));
assert_eq!(store.live_token_count("/doc.pdf"), 0);
}
#[test]
fn store_evicts_oldest_token_beyond_two_per_source() {
let store = PdfSaveTokenStore::default();
let t1 = store.insert("/doc.pdf", "sha", hello_pdf());
let t2 = store.insert("/doc.pdf", "sha", hello_pdf());
let t3 = store.insert("/doc.pdf", "sha", hello_pdf());
assert_eq!(store.live_token_count("/doc.pdf"), 2);
assert!(store.peek(&t1).is_err(), "oldest token must be evicted");
assert!(store.peek(&t2).is_ok());
assert!(store.peek(&t3).is_ok());
}
#[test]
fn peek_unknown_token_is_write_conflict() {
let store = PdfSaveTokenStore::default();
let err = store.peek("does-not-exist").unwrap_err();
assert!(matches!(err, PdfEditError::WriteConflict));
}
#[test]
fn commit_consumes_token_so_replay_fails() {
let store = PdfSaveTokenStore::default();
let sha = document_sha256(&hello_pdf());
let token = store.insert("/doc.pdf", &sha, hello_pdf());
let first = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
assert!(first.is_ok());
let replay = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
let err = replay.unwrap_err();
assert!(matches!(err, PdfEditError::WriteConflict));
}
#[test]
fn commit_rejects_expired_token() {
let store = PdfSaveTokenStore::default();
let token = random_save_token();
{
let mut inner = store.inner.lock().unwrap();
inner
.by_source
.entry("/doc.pdf".to_string())
.or_default()
.push_back(token.clone());
inner.tokens.insert(
token.clone(),
SaveTokenRecord {
source_path: "/doc.pdf".to_string(),
source_sha256: document_sha256(&hello_pdf()),
candidate_bytes: hello_pdf(),
created_at: Instant::now() - TOKEN_TTL - Duration::from_secs(1),
},
);
}
let err = resolve_commit(&store, &token, None, |_| Ok(hello_pdf())).unwrap_err();
assert!(matches!(err, PdfEditError::WriteConflict));
assert!(store.peek(&token).is_err());
}
#[test]
fn commit_rejects_source_changed_since_prepare_for_same_path_save() {
let store = PdfSaveTokenStore::default();
let prepare_time_sha = document_sha256(&hello_pdf());
let token = store.insert("/doc.pdf", &prepare_time_sha, hello_pdf());
let different_current_bytes = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
let err = resolve_commit(
&store,
&token,
None, |_| Ok(different_current_bytes.clone()),
)
.unwrap_err();
assert!(matches!(err, PdfEditError::WriteConflict));
}
#[test]
fn commit_allows_same_path_save_when_source_unchanged() {
let store = PdfSaveTokenStore::default();
let source_bytes = hello_pdf();
let prepare_time_sha = document_sha256(&source_bytes);
let token = store.insert("/doc.pdf", &prepare_time_sha, source_bytes.clone());
let (source_path, bytes, result) =
resolve_commit(&store, &token, None, |_| Ok(source_bytes.clone()))
.expect("unchanged source commits cleanly");
assert_eq!(source_path, "/doc.pdf");
assert_eq!(result.target_path, "/doc.pdf");
assert_eq!(bytes, source_bytes);
}
#[test]
fn commit_save_as_does_not_recheck_source_sha() {
let store = PdfSaveTokenStore::default();
let sha = document_sha256(&hello_pdf());
let token = store.insert("/doc.pdf", &sha, hello_pdf());
let (source_path, _bytes, result) =
resolve_commit(&store, &token, Some("/doc-copy.pdf".to_string()), |_| {
panic!("same-path recheck must not run for Save As")
})
.expect("Save As commits without a same-path recheck");
assert_eq!(source_path, "/doc.pdf");
assert_eq!(result.target_path, "/doc-copy.pdf");
}
#[test]
fn invalidate_source_drops_every_remaining_token_for_that_path() {
let store = PdfSaveTokenStore::default();
let sha = document_sha256(&hello_pdf());
let t1 = store.insert("/doc.pdf", &sha, hello_pdf());
assert!(store.peek(&t1).is_ok());
store.invalidate_source("/doc.pdf");
assert!(store.peek(&t1).is_err());
assert_eq!(store.live_token_count("/doc.pdf"), 0);
}
#[test]
fn select_compose_base_uses_source_for_sentinel_annotation_bytes() {
let source = hello_pdf();
let sentinel = vec![0u8];
let base = select_compose_base(&source, &sentinel).expect("sentinel selects source");
assert_eq!(base, source.as_slice());
}
#[test]
fn select_compose_base_uses_annotation_bytes_when_incremental_update() {
let source = hello_pdf();
let mut annotation_pdf = source.clone();
annotation_pdf.extend_from_slice(b"\n% appended annotation objects + new xref\n");
let base =
select_compose_base(&source, &annotation_pdf).expect("incremental update selects it");
assert_eq!(base, annotation_pdf.as_slice());
}
#[test]
fn select_compose_base_rejects_annotation_bytes_not_extending_source() {
let source = hello_pdf();
let unrelated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Different) Tj ET");
let err = select_compose_base(&source, &unrelated).unwrap_err();
assert!(matches!(err, PdfEditError::AnnotationSerializationFailed));
}
#[test]
fn expected_text_checks_derives_one_entry_per_command() {
use crate::model::{
PdfColor, PdfPoint, PdfRect, PdfSourceLocator, PdfTextAppearance, PdfTextEditState,
};
let appearance = PdfTextAppearance {
font_id: "f".to_string(),
family: "F".to_string(),
size_pt: 12.0,
color: PdfColor {
r: 0.0,
g: 0.0,
b: 0.0,
alpha: 1.0,
},
};
let commands = vec![
PdfEditCommand::ReplaceSourceText {
id: "e1".to_string(),
locator: PdfSourceLocator {
document_sha256: "sha".to_string(),
page: 2,
spans: vec![],
island_digest: "digest".to_string(),
},
before: PdfTextEditState {
text: "old".to_string(),
appearance: appearance.clone(),
},
after: PdfTextEditState {
text: "new text".to_string(),
appearance: appearance.clone(),
},
available_bounds: PdfRect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
},
},
PdfEditCommand::AddText {
id: "a1".to_string(),
page: 3,
anchor: PdfPoint { x: 1.0, y: 1.0 },
text: "added text".to_string(),
appearance,
},
];
let checks = expected_text_checks(&commands);
assert_eq!(checks.len(), 2);
assert_eq!(
checks[0],
ExpectedTextCheck {
page: 2,
text: "new text".to_string()
}
);
assert_eq!(
checks[1],
ExpectedTextCheck {
page: 3,
text: "added text".to_string()
}
);
}
#[test]
fn expected_text_checks_last_ocr_correction_wins_by_line_id() {
use crate::model::{PdfColor, PdfOcrBackgroundProof, PdfRect, PdfTextAppearance};
let appearance = PdfTextAppearance {
font_id: "f".to_string(),
family: "F".to_string(),
size_pt: 12.0,
color: PdfColor {
r: 0.0,
g: 0.0,
b: 0.0,
alpha: 1.0,
},
};
let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
id: id.to_string(),
page: 4,
line_id: "line-1".to_string(),
rect: PdfRect {
x: 1.0,
y: 1.0,
width: 100.0,
height: 20.0,
},
text: text.to_string(),
appearance: appearance.clone(),
cover_color: PdfColor {
r: 1.0,
g: 1.0,
b: 1.0,
alpha: 1.0,
},
background_proof: PdfOcrBackgroundProof::NearUniform,
};
let checks = expected_text_checks(&[correction("old", "old"), correction("new", "new")]);
assert_eq!(
checks,
vec![ExpectedTextCheck {
page: 4,
text: "new".to_string()
}]
);
}
fn inherited_source() -> Vec<u8> {
crate::test_fixtures::pdf_bytes(&crate::test_fixtures::inherited_resources_pdf(
b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
))
}
fn candidate_that_hides_the_inherited_resources(source: &[u8]) -> Vec<u8> {
let mut doc = lopdf::Document::load_mem(source).expect("source loads");
let page_id = doc.get_pages()[&1];
let some_object = *doc.objects.keys().next().expect("an object");
if let Ok(lopdf::Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set(
"Resources",
lopdf::dictionary! { "Font" => lopdf::dictionary! { "VREdit1" => some_object } },
);
}
crate::test_fixtures::pdf_bytes(&doc)
}
#[test]
fn finding2_staging_against_the_base_refuses_a_page_that_lost_its_inherited_resources() {
let source = inherited_source();
let sha = document_sha256(&source);
let hidden = candidate_that_hides_the_inherited_resources(&source);
let store = PdfSaveTokenStore::default();
let err = stage_prepared_save_against_base(
&store,
"/doc.pdf",
&sha,
&sha,
&source,
hidden.clone(),
vec![1],
vec![],
)
.unwrap_err();
assert!(matches!(err, PdfEditError::VerificationFailed));
assert_eq!(store.live_token_count("/doc.pdf"), 0, "no token for it");
stage_prepared_save(&store, "/doc.pdf", &sha, &sha, hidden, vec![1], vec![])
.expect("structurally fine, which is all the digest-only path can check");
}
#[test]
fn finding2_staging_against_the_base_accepts_a_candidate_that_changes_no_resource() {
let source = inherited_source();
let sha = document_sha256(&source);
let store = PdfSaveTokenStore::default();
stage_prepared_save_against_base(
&store,
"/doc.pdf",
&sha,
&sha,
&source,
source.clone(),
vec![],
vec![],
)
.expect("a candidate identical to its base");
}
#[test]
fn finding2_an_unparseable_base_is_a_verification_failure() {
let store = PdfSaveTokenStore::default();
let err = stage_prepared_save_against_base(
&store,
"/doc.pdf",
"sha",
"sha",
b"not a pdf",
hello_pdf(),
vec![],
vec![],
)
.unwrap_err();
assert!(matches!(err, PdfEditError::VerificationFailed));
}
#[test]
fn finding6_the_recheck_runs_when_the_comparison_says_the_target_is_the_source() {
let store = PdfSaveTokenStore::default();
let sha = document_sha256(&hello_pdf());
let changed = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
resolve_commit(&store, &token, Some("/tmp/./doc.pdf".to_string()), |_| {
panic!("string comparison skips the recheck for an alias")
})
.expect("the alias gets past string equality");
let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
let err = resolve_commit_with(
&store,
&token,
Some("/tmp/./doc.pdf".to_string()),
|source, target| {
assert_eq!((source, target), ("/tmp/doc.pdf", "/tmp/./doc.pdf"));
true
},
|_| Ok(changed.clone()),
)
.unwrap_err();
assert!(matches!(err, PdfEditError::WriteConflict));
let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
resolve_commit_with(
&store,
&token,
Some("/tmp/other.pdf".to_string()),
|_, _| false,
|_| panic!("a different file is not rechecked"),
)
.expect("Save As");
}
#[test]
fn finding5_the_store_reports_a_tokens_source_and_every_live_source() {
let store = PdfSaveTokenStore::default();
let t1 = store.insert("/a.pdf", "sha", hello_pdf());
store.insert("/b.pdf", "sha", hello_pdf());
assert_eq!(store.source_path_of(&t1).as_deref(), Some("/a.pdf"));
assert_eq!(store.source_path_of("unknown"), None);
let mut sources = store.source_paths();
sources.sort();
assert_eq!(sources, vec!["/a.pdf".to_string(), "/b.pdf".to_string()]);
resolve_commit(&store, &t1, Some("/c.pdf".to_string()), |_| Ok(vec![])).expect("save as");
assert_eq!(store.source_path_of(&t1), None, "consumed");
}
}