Skip to main content

pdf_manip/text_edit/
token.rs

1//! MatchId token encoding and revision fingerprints.
2//!
3//! Wire format (design §10.2): `pdfluent-match-v1.<base64url(JSON payload)>`.
4//! The token is opaque to consumers; decoding enforces version, length and
5//! range limits, and every locator is fully revalidated against the live
6//! document before use.
7
8use sha2::{Digest, Sha256};
9
10use super::{StaleReason, TextEditError};
11
12/// Token prefix including the format version.
13const TOKEN_PREFIX: &str = "pdfluent-match-v1.";
14/// Hard ceiling on the encoded token length (design §10.2: strict limits).
15const MAX_TOKEN_LEN: usize = 4096;
16
17// ---------------------------------------------------------------------------
18// Revision fingerprint
19// ---------------------------------------------------------------------------
20
21/// Identifies one exact revision of a document during text editing.
22///
23/// Composed of a SHA-256 digest of the exact source bytes plus an in-session
24/// commit counter (design §10.1). The PDF `/ID` entry is never used as a
25/// substitute for the byte digest. Every successful commit produces the next
26/// revision (`counter + 1`), invalidating all previously issued [`super::MatchId`]s.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize))]
29pub struct DocumentRevision {
30    digest: [u8; 32],
31    counter: u64,
32}
33
34impl DocumentRevision {
35    /// Fingerprint the exact input bytes of a document (commit counter 0).
36    ///
37    /// Reopening byte-identical input yields an equal revision.
38    pub fn from_source_bytes(bytes: &[u8]) -> Self {
39        let mut hasher = Sha256::new();
40        hasher.update(bytes);
41        let digest: [u8; 32] = hasher.finalize().into();
42        Self { digest, counter: 0 }
43    }
44
45    /// The revision after one more committed transaction.
46    #[must_use]
47    pub fn next(&self) -> Self {
48        Self {
49            digest: self.digest,
50            counter: self.counter + 1,
51        }
52    }
53
54    /// Commit counter component (0 for freshly opened bytes).
55    pub fn counter(&self) -> u64 {
56        self.counter
57    }
58
59    pub(crate) fn digest_hex(&self) -> String {
60        hex(&self.digest)
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Hash helpers
66// ---------------------------------------------------------------------------
67
68/// 64-bit content hash (first 8 bytes of SHA-256), hex-encoded.
69pub(crate) fn hash64_hex(data: &[u8]) -> String {
70    let mut hasher = Sha256::new();
71    hasher.update(data);
72    let digest = hasher.finalize();
73    hex(&digest[..8])
74}
75
76fn hex(bytes: &[u8]) -> String {
77    let mut s = String::with_capacity(bytes.len() * 2);
78    for b in bytes {
79        use std::fmt::Write;
80        let _ = write!(s, "{b:02x}");
81    }
82    s
83}
84
85// ---------------------------------------------------------------------------
86// Token payload
87// ---------------------------------------------------------------------------
88
89/// Versioned JSON payload inside a MatchId token. Internal only — the schema
90/// may evolve behind the version prefix.
91///
92/// Encoded/decoded with a hand-rolled strict codec for exactly this flat
93/// schema (design §10.2), so the engine does not depend on the optional
94/// `serde` feature.
95#[derive(Debug, Clone, PartialEq)]
96pub(crate) struct TokenPayload {
97    /// Payload schema version (currently 1).
98    pub v: u8,
99    /// Hex SHA-256 digest of the source bytes.
100    pub fp: String,
101    /// Commit counter of the revision that produced the match.
102    pub ctr: u64,
103    /// 1-based page number.
104    pub page: u32,
105    /// Container key: `"p"` for the page's logical stream sequence, or
106    /// `"x:<name>/<name>…"` for a Form XObject resource path.
107    pub ck: String,
108    /// Byte range of the match inside the container's combined visual text.
109    pub chr: [u64; 2],
110    /// hash64 of the matched text bytes.
111    pub sh: String,
112    /// hash64 of the surrounding text context (±32 bytes).
113    pub ch: String,
114}
115
116impl TokenPayload {
117    pub(crate) fn matches_revision(&self, rev: &DocumentRevision) -> bool {
118        self.ctr == rev.counter && self.fp == rev.digest_hex()
119    }
120}
121
122/// Encode a payload into the opaque token string.
123pub(crate) fn encode_token(payload: &TokenPayload) -> String {
124    let json = format!(
125        r#"{{"v":{},"fp":{},"ctr":{},"page":{},"ck":{},"chr":[{},{}],"sh":{},"ch":{}}}"#,
126        payload.v,
127        json_string(&payload.fp),
128        payload.ctr,
129        payload.page,
130        json_string(&payload.ck),
131        payload.chr[0],
132        payload.chr[1],
133        json_string(&payload.sh),
134        json_string(&payload.ch),
135    );
136    format!("{TOKEN_PREFIX}{}", base64url_encode(json.as_bytes()))
137}
138
139/// Decode and strictly validate a token string.
140pub(crate) fn decode_token(token: &str) -> Result<TokenPayload, TextEditError> {
141    let invalid = |why: &str| TextEditError::InvalidMatchId {
142        reason: why.to_string(),
143    };
144    if token.len() > MAX_TOKEN_LEN {
145        return Err(invalid("token exceeds maximum length"));
146    }
147    let Some(body) = token.strip_prefix(TOKEN_PREFIX) else {
148        return Err(invalid("unrecognized token format/version prefix"));
149    };
150    let bytes = base64url_decode(body).ok_or_else(|| invalid("invalid base64url body"))?;
151    let json = std::str::from_utf8(&bytes).map_err(|_| invalid("payload is not UTF-8"))?;
152    let payload = parse_payload(json).map_err(|e| invalid(&format!("invalid payload: {e}")))?;
153    if payload.v != 1 {
154        return Err(invalid("unsupported payload version"));
155    }
156    if payload.fp.len() != 64 || !payload.fp.bytes().all(|b| b.is_ascii_hexdigit()) {
157        return Err(invalid("malformed fingerprint"));
158    }
159    if payload.sh.len() != 16 || payload.ch.len() != 16 {
160        return Err(invalid("malformed content hashes"));
161    }
162    if payload.page == 0 {
163        return Err(invalid("page numbers are 1-based"));
164    }
165    if payload.chr[1] <= payload.chr[0] || payload.chr[1] > u64::from(u32::MAX) {
166        return Err(invalid("character range out of bounds"));
167    }
168    if !(payload.ck == "p" || payload.ck.starts_with("x:")) {
169        return Err(invalid("unrecognized container key"));
170    }
171    Ok(payload)
172}
173
174/// Check a decoded payload against the session revision.
175pub(crate) fn check_revision(
176    payload: &TokenPayload,
177    rev: &DocumentRevision,
178    id: &super::MatchId,
179) -> Result<(), TextEditError> {
180    if !payload.matches_revision(rev) {
181        return Err(TextEditError::StaleMatch {
182            match_id: id.clone(),
183            reason: StaleReason::RevisionChanged,
184        });
185    }
186    Ok(())
187}
188
189// ---------------------------------------------------------------------------
190// Minimal strict JSON codec for the flat TokenPayload schema
191// ---------------------------------------------------------------------------
192
193/// Serialize a Rust string as a JSON string literal.
194fn json_string(s: &str) -> String {
195    let mut out = String::with_capacity(s.len() + 2);
196    out.push('"');
197    for c in s.chars() {
198        match c {
199            '"' => out.push_str("\\\""),
200            '\\' => out.push_str("\\\\"),
201            '\n' => out.push_str("\\n"),
202            '\r' => out.push_str("\\r"),
203            '\t' => out.push_str("\\t"),
204            c if (c as u32) < 0x20 => {
205                use std::fmt::Write;
206                let _ = write!(out, "\\u{:04x}", c as u32);
207            }
208            c => out.push(c),
209        }
210    }
211    out.push('"');
212    out
213}
214
215/// Strict parser for exactly the TokenPayload object: one flat JSON object,
216/// known keys only, each key exactly once. Anything else is an error.
217fn parse_payload(json: &str) -> Result<TokenPayload, String> {
218    let mut p = Parser {
219        b: json.as_bytes(),
220        i: 0,
221    };
222    let mut v: Option<u64> = None;
223    let mut fp: Option<String> = None;
224    let mut ctr: Option<u64> = None;
225    let mut page: Option<u64> = None;
226    let mut ck: Option<String> = None;
227    let mut chr: Option<[u64; 2]> = None;
228    let mut sh: Option<String> = None;
229    let mut ch: Option<String> = None;
230
231    p.expect(b'{')?;
232    loop {
233        let key = p.parse_string()?;
234        p.expect(b':')?;
235        let dup = |name: &str| format!("duplicate key '{name}'");
236        match key.as_str() {
237            "v" => {
238                if v.replace(p.parse_u64()?).is_some() {
239                    return Err(dup("v"));
240                }
241            }
242            "fp" => {
243                if fp.replace(p.parse_string()?).is_some() {
244                    return Err(dup("fp"));
245                }
246            }
247            "ctr" => {
248                if ctr.replace(p.parse_u64()?).is_some() {
249                    return Err(dup("ctr"));
250                }
251            }
252            "page" => {
253                if page.replace(p.parse_u64()?).is_some() {
254                    return Err(dup("page"));
255                }
256            }
257            "ck" => {
258                if ck.replace(p.parse_string()?).is_some() {
259                    return Err(dup("ck"));
260                }
261            }
262            "chr" => {
263                p.expect(b'[')?;
264                let a = p.parse_u64()?;
265                p.expect(b',')?;
266                let b = p.parse_u64()?;
267                p.expect(b']')?;
268                if chr.replace([a, b]).is_some() {
269                    return Err(dup("chr"));
270                }
271            }
272            "sh" => {
273                if sh.replace(p.parse_string()?).is_some() {
274                    return Err(dup("sh"));
275                }
276            }
277            "ch" => {
278                if ch.replace(p.parse_string()?).is_some() {
279                    return Err(dup("ch"));
280                }
281            }
282            other => return Err(format!("unknown key '{other}'")),
283        }
284        match p.next_byte()? {
285            b',' => continue,
286            b'}' => break,
287            other => return Err(format!("unexpected byte 0x{other:02x}")),
288        }
289    }
290    p.skip_ws();
291    if p.i != p.b.len() {
292        return Err("trailing data after object".to_string());
293    }
294
295    let v = v.ok_or("missing 'v'")?;
296    if v > u64::from(u8::MAX) {
297        return Err("'v' out of range".to_string());
298    }
299    let page = page.ok_or("missing 'page'")?;
300    if page > u64::from(u32::MAX) {
301        return Err("'page' out of range".to_string());
302    }
303    Ok(TokenPayload {
304        v: v as u8,
305        fp: fp.ok_or("missing 'fp'")?,
306        ctr: ctr.ok_or("missing 'ctr'")?,
307        page: page as u32,
308        ck: ck.ok_or("missing 'ck'")?,
309        chr: chr.ok_or("missing 'chr'")?,
310        sh: sh.ok_or("missing 'sh'")?,
311        ch: ch.ok_or("missing 'ch'")?,
312    })
313}
314
315struct Parser<'a> {
316    b: &'a [u8],
317    i: usize,
318}
319
320impl Parser<'_> {
321    fn skip_ws(&mut self) {
322        while let Some(&c) = self.b.get(self.i) {
323            if matches!(c, b' ' | b'\t' | b'\n' | b'\r') {
324                self.i += 1;
325            } else {
326                break;
327            }
328        }
329    }
330
331    fn next_byte(&mut self) -> Result<u8, String> {
332        self.skip_ws();
333        let c = *self.b.get(self.i).ok_or("unexpected end of input")?;
334        self.i += 1;
335        Ok(c)
336    }
337
338    fn expect(&mut self, want: u8) -> Result<(), String> {
339        let got = self.next_byte()?;
340        if got == want {
341            Ok(())
342        } else {
343            Err(format!("expected '{}', got 0x{got:02x}", want as char))
344        }
345    }
346
347    fn parse_u64(&mut self) -> Result<u64, String> {
348        self.skip_ws();
349        let start = self.i;
350        while self.b.get(self.i).is_some_and(u8::is_ascii_digit) {
351            self.i += 1;
352        }
353        if self.i == start {
354            return Err("expected a number".to_string());
355        }
356        std::str::from_utf8(&self.b[start..self.i])
357            .ok()
358            .and_then(|s| s.parse().ok())
359            .ok_or_else(|| "number out of range".to_string())
360    }
361
362    fn parse_string(&mut self) -> Result<String, String> {
363        self.expect(b'"')?;
364        let mut out = String::new();
365        loop {
366            let c = *self.b.get(self.i).ok_or("unterminated string")?;
367            self.i += 1;
368            match c {
369                b'"' => return Ok(out),
370                b'\\' => {
371                    let esc = *self.b.get(self.i).ok_or("unterminated escape")?;
372                    self.i += 1;
373                    match esc {
374                        b'"' => out.push('"'),
375                        b'\\' => out.push('\\'),
376                        b'/' => out.push('/'),
377                        b'b' => out.push('\u{0008}'),
378                        b'f' => out.push('\u{000C}'),
379                        b'n' => out.push('\n'),
380                        b'r' => out.push('\r'),
381                        b't' => out.push('\t'),
382                        b'u' => {
383                            let cp = self.parse_hex4()?;
384                            if (0xD800..=0xDBFF).contains(&cp) {
385                                // Surrogate pair.
386                                if self.b.get(self.i) != Some(&b'\\')
387                                    || self.b.get(self.i + 1) != Some(&b'u')
388                                {
389                                    return Err("lone high surrogate".to_string());
390                                }
391                                self.i += 2;
392                                let lo = self.parse_hex4()?;
393                                if !(0xDC00..=0xDFFF).contains(&lo) {
394                                    return Err("invalid low surrogate".to_string());
395                                }
396                                let combined = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
397                                out.push(char::from_u32(combined).ok_or("invalid surrogate pair")?);
398                            } else if (0xDC00..=0xDFFF).contains(&cp) {
399                                return Err("lone low surrogate".to_string());
400                            } else {
401                                out.push(char::from_u32(cp).ok_or("invalid codepoint")?);
402                            }
403                        }
404                        other => return Err(format!("invalid escape 0x{other:02x}")),
405                    }
406                }
407                c if c < 0x20 => return Err("raw control character in string".to_string()),
408                _ => {
409                    // Re-assemble the UTF-8 sequence starting at c.
410                    let len = utf8_len(c).ok_or("invalid UTF-8 in string")?;
411                    let start = self.i - 1;
412                    let end = start + len;
413                    if end > self.b.len() {
414                        return Err("truncated UTF-8 sequence".to_string());
415                    }
416                    let s = std::str::from_utf8(&self.b[start..end])
417                        .map_err(|_| "invalid UTF-8 in string".to_string())?;
418                    out.push_str(s);
419                    self.i = end;
420                }
421            }
422        }
423    }
424
425    fn parse_hex4(&mut self) -> Result<u32, String> {
426        if self.i + 4 > self.b.len() {
427            return Err("truncated \\u escape".to_string());
428        }
429        let s = std::str::from_utf8(&self.b[self.i..self.i + 4])
430            .map_err(|_| "invalid \\u escape".to_string())?;
431        self.i += 4;
432        u32::from_str_radix(s, 16).map_err(|_| "invalid \\u escape".to_string())
433    }
434}
435
436fn utf8_len(first: u8) -> Option<usize> {
437    match first {
438        0x00..=0x7F => Some(1),
439        0xC2..=0xDF => Some(2),
440        0xE0..=0xEF => Some(3),
441        0xF0..=0xF4 => Some(4),
442        _ => None,
443    }
444}
445
446// ---------------------------------------------------------------------------
447// base64url (no padding, RFC 4648 §5)
448// ---------------------------------------------------------------------------
449
450const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
451
452fn base64url_encode(data: &[u8]) -> String {
453    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
454    for chunk in data.chunks(3) {
455        let b0 = chunk[0] as u32;
456        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
457        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
458        let n = (b0 << 16) | (b1 << 8) | b2;
459        out.push(B64_ALPHABET[(n >> 18) as usize & 63] as char);
460        out.push(B64_ALPHABET[(n >> 12) as usize & 63] as char);
461        if chunk.len() > 1 {
462            out.push(B64_ALPHABET[(n >> 6) as usize & 63] as char);
463        }
464        if chunk.len() > 2 {
465            out.push(B64_ALPHABET[n as usize & 63] as char);
466        }
467    }
468    out
469}
470
471fn base64url_decode(s: &str) -> Option<Vec<u8>> {
472    fn val(c: u8) -> Option<u32> {
473        match c {
474            b'A'..=b'Z' => Some((c - b'A') as u32),
475            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
476            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
477            b'-' => Some(62),
478            b'_' => Some(63),
479            _ => None,
480        }
481    }
482    let bytes = s.as_bytes();
483    if bytes.len() % 4 == 1 {
484        return None;
485    }
486    let mut out = Vec::with_capacity(bytes.len() / 4 * 3 + 2);
487    for chunk in bytes.chunks(4) {
488        let mut n: u32 = 0;
489        for (i, &c) in chunk.iter().enumerate() {
490            n |= val(c)? << (18 - 6 * i);
491        }
492        out.push((n >> 16) as u8);
493        if chunk.len() > 2 {
494            out.push((n >> 8) as u8);
495        }
496        if chunk.len() > 3 {
497            out.push(n as u8);
498        }
499    }
500    Some(out)
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn base64url_roundtrip() {
509        for len in 0..40 {
510            let data: Vec<u8> = (0..len as u8)
511                .map(|i| i.wrapping_mul(37).wrapping_add(3))
512                .collect();
513            let enc = base64url_encode(&data);
514            assert!(!enc.contains('='));
515            assert_eq!(base64url_decode(&enc).unwrap(), data);
516        }
517    }
518
519    #[test]
520    fn token_roundtrip_and_validation() {
521        let rev = DocumentRevision::from_source_bytes(b"pdf bytes");
522        let payload = TokenPayload {
523            v: 1,
524            fp: rev.digest_hex(),
525            ctr: 0,
526            page: 1,
527            ck: "p".into(),
528            chr: [4, 9],
529            sh: hash64_hex(b"match"),
530            ch: hash64_hex(b"context"),
531        };
532        let token = encode_token(&payload);
533        assert!(token.starts_with("pdfluent-match-v1."));
534        assert_eq!(decode_token(&token).unwrap(), payload);
535
536        assert!(decode_token("garbage").is_err());
537        assert!(decode_token("pdfluent-match-v1.!!!").is_err());
538    }
539
540    #[test]
541    fn revision_semantics() {
542        let a = DocumentRevision::from_source_bytes(b"same");
543        let b = DocumentRevision::from_source_bytes(b"same");
544        assert_eq!(a, b, "byte-identical input yields the same fingerprint");
545        assert_ne!(a, a.next(), "a commit produces a new revision");
546        assert_ne!(a, DocumentRevision::from_source_bytes(b"other"));
547    }
548}