1use sha2::{Digest, Sha256};
15
16use super::{StaleReason, TextEditError};
17
18const TOKEN_PREFIX: &str = "pdfluent-match-v1.";
20const MAX_TOKEN_LEN: usize = 4096;
22
23#[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 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 #[must_use]
53 pub fn next(&self) -> Self {
54 Self {
55 digest: self.digest,
56 counter: self.counter + 1,
57 }
58 }
59
60 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
70pub(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#[derive(Debug, Clone, PartialEq)]
102pub(crate) struct TokenPayload {
103 pub v: u8,
105 pub fp: String,
107 pub ctr: u64,
109 pub page: u32,
111 pub ck: String,
114 pub chr: [u64; 2],
116 pub sh: String,
118 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
128pub(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
145pub(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
180pub(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
195fn 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
221fn 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 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 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
452const 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}