1use sha2::{Digest, Sha256};
9
10use super::{StaleReason, TextEditError};
11
12const TOKEN_PREFIX: &str = "pdfluent-match-v1.";
14const MAX_TOKEN_LEN: usize = 4096;
16
17#[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 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 #[must_use]
47 pub fn next(&self) -> Self {
48 Self {
49 digest: self.digest,
50 counter: self.counter + 1,
51 }
52 }
53
54 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
64pub(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#[derive(Debug, Clone, PartialEq)]
96pub(crate) struct TokenPayload {
97 pub v: u8,
99 pub fp: String,
101 pub ctr: u64,
103 pub page: u32,
105 pub ck: String,
108 pub chr: [u64; 2],
110 pub sh: String,
112 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
122pub(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
139pub(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
174pub(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
189fn 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
215fn 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 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 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
446const 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}