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