1use flate2::Compression;
15use flate2::read::{ZlibDecoder, ZlibEncoder};
16use hmac::{Hmac, Mac};
17use sha2::{Digest, Sha256};
18use std::io::Read;
19use uuid::Uuid;
20
21#[cfg(feature = "wasm")]
22use wasm_bindgen::prelude::*;
23
24#[cfg(not(target_arch = "wasm32"))]
25mod native_signed_url;
26
27#[cfg(not(target_arch = "wasm32"))]
28pub use native_signed_url::{
29 SignedUrlBuildRequest, SignedUrlParams, VerifyError, generate_signed_url, verify_signed_url,
30};
31
32mod patch;
33pub use patch::{
34 apply_patch, create_patch, reconstruct_version, reconstruct_version_native, squash_patches,
35 squash_patches_native,
36};
37
38type HmacSha256 = Hmac<Sha256>;
39
40const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
41
42#[cfg_attr(feature = "wasm", wasm_bindgen)]
48pub fn encode_base62(mut num: u64) -> String {
49 if num == 0 {
50 return "0".to_string();
51 }
52 let mut result = Vec::new();
53 while num > 0 {
54 result.push(BASE62[(num % 62) as usize]);
55 num /= 62;
56 }
57 result.reverse();
58 String::from_utf8(result).unwrap_or_default()
59}
60
61#[cfg_attr(feature = "wasm", wasm_bindgen)]
63pub fn decode_base62(s: &str) -> u64 {
64 let mut result: u64 = 0;
65 for byte in s.bytes() {
66 let val = match byte {
67 b'0'..=b'9' => byte - b'0',
68 b'A'..=b'Z' => byte - b'A' + 10,
69 b'a'..=b'z' => byte - b'a' + 36,
70 _ => 0,
71 } as u64;
72 result = result * 62 + val;
73 }
74 result
75}
76
77#[cfg_attr(feature = "wasm", wasm_bindgen)]
87pub fn compress(data: &str) -> Result<Vec<u8>, String> {
88 let mut encoder = ZlibEncoder::new(data.as_bytes(), Compression::default());
89 let mut compressed = Vec::new();
90 encoder
91 .read_to_end(&mut compressed)
92 .map_err(|e| format!("compression failed: {e}"))?;
93 Ok(compressed)
94}
95
96#[cfg_attr(feature = "wasm", wasm_bindgen)]
103pub fn decompress(data: &[u8]) -> Result<String, String> {
104 let mut decoder = ZlibDecoder::new(data);
105 let mut decompressed = Vec::new();
106 decoder
107 .read_to_end(&mut decompressed)
108 .map_err(|e| format!("decompression failed: {e}"))?;
109 String::from_utf8(decompressed).map_err(|e| format!("invalid UTF-8: {e}"))
110}
111
112#[cfg_attr(feature = "wasm", wasm_bindgen)]
116pub fn generate_id() -> String {
117 let uuid = Uuid::new_v4();
118 let hex = uuid.simple().to_string();
119 let hex_prefix = &hex[..16];
120 let num = u64::from_str_radix(hex_prefix, 16).unwrap_or(0);
121 let base62 = encode_base62(num);
122 format!("{:0>8}", &base62[..base62.len().min(8)])
123}
124
125#[cfg_attr(feature = "wasm", wasm_bindgen)]
129pub fn hash_content(data: &str) -> String {
130 let mut hasher = Sha256::new();
131 hasher.update(data.as_bytes());
132 hex::encode(hasher.finalize())
133}
134
135#[cfg_attr(feature = "wasm", wasm_bindgen)]
139pub fn calculate_tokens(text: &str) -> u32 {
140 let len = text.len() as f64;
141 (len / 4.0).ceil() as u32
142}
143
144#[cfg_attr(feature = "wasm", wasm_bindgen)]
149pub fn calculate_compression_ratio(original_size: u32, compressed_size: u32) -> f64 {
150 if compressed_size == 0 {
151 return 1.0;
152 }
153 let ratio = original_size as f64 / compressed_size as f64;
154 (ratio * 100.0).round() / 100.0
155}
156
157#[cfg_attr(feature = "wasm", wasm_bindgen)]
163pub fn compute_signature(
164 slug: &str,
165 agent_id: &str,
166 conversation_id: &str,
167 expires_at: f64,
168 secret: &str,
169) -> String {
170 compute_signature_with_length(slug, agent_id, conversation_id, expires_at, secret, 16)
171}
172
173#[cfg_attr(feature = "wasm", wasm_bindgen)]
178pub fn compute_signature_with_length(
179 slug: &str,
180 agent_id: &str,
181 conversation_id: &str,
182 expires_at: f64,
183 secret: &str,
184 sig_length: usize,
185) -> String {
186 let payload = format!(
187 "{}:{}:{}:{}",
188 slug, agent_id, conversation_id, expires_at as u64
189 );
190 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
191 return String::new();
192 };
193 mac.update(payload.as_bytes());
194 let result = mac.finalize();
195 let hex_full = hex::encode(result.into_bytes());
196 let len = sig_length.min(64);
197 hex_full[..len].to_string()
198}
199
200#[cfg_attr(feature = "wasm", wasm_bindgen)]
204pub fn compute_org_signature(
205 slug: &str,
206 agent_id: &str,
207 conversation_id: &str,
208 org_id: &str,
209 expires_at: f64,
210 secret: &str,
211) -> String {
212 compute_org_signature_with_length(
213 slug,
214 agent_id,
215 conversation_id,
216 org_id,
217 expires_at,
218 secret,
219 32,
220 )
221}
222
223#[cfg_attr(feature = "wasm", wasm_bindgen)]
225pub fn compute_org_signature_with_length(
226 slug: &str,
227 agent_id: &str,
228 conversation_id: &str,
229 org_id: &str,
230 expires_at: f64,
231 secret: &str,
232 sig_length: usize,
233) -> String {
234 let payload = format!(
235 "{}:{}:{}:{}:{}",
236 slug, agent_id, conversation_id, org_id, expires_at as u64
237 );
238 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
239 return String::new();
240 };
241 mac.update(payload.as_bytes());
242 let result = mac.finalize();
243 let hex_full = hex::encode(result.into_bytes());
244 let len = sig_length.min(64);
245 hex_full[..len].to_string()
246}
247
248#[cfg_attr(feature = "wasm", wasm_bindgen)]
251pub fn derive_signing_key(api_key: &str) -> String {
252 let Ok(mut mac) = HmacSha256::new_from_slice(api_key.as_bytes()) else {
253 return String::new();
254 };
255 mac.update(b"llmtxt-signing");
256 hex::encode(mac.finalize().into_bytes())
257}
258
259#[cfg_attr(feature = "wasm", wasm_bindgen)]
266pub fn is_expired(expires_at_ms: f64) -> bool {
267 if expires_at_ms == 0.0 {
268 return false;
269 }
270 let now = current_time_ms();
271 now > expires_at_ms
272}
273
274#[cfg(target_arch = "wasm32")]
277fn current_time_ms() -> f64 {
278 js_sys::Date::now()
279}
280
281#[cfg(not(target_arch = "wasm32"))]
283fn current_time_ms() -> f64 {
284 std::time::SystemTime::now()
285 .duration_since(std::time::UNIX_EPOCH)
286 .map(|d| d.as_millis() as f64)
287 .unwrap_or(0.0)
288}
289
290#[cfg_attr(feature = "wasm", wasm_bindgen)]
297pub fn text_similarity(a: &str, b: &str) -> f64 {
298 text_similarity_ngram(a, b, 3)
299}
300
301#[cfg_attr(feature = "wasm", wasm_bindgen)]
303pub fn text_similarity_ngram(a: &str, b: &str, n: usize) -> f64 {
304 let a_lower = a.to_lowercase();
305 let b_lower = b.to_lowercase();
306 let a_norm: String = a_lower.split_whitespace().collect::<Vec<_>>().join(" ");
307 let b_norm: String = b_lower.split_whitespace().collect::<Vec<_>>().join(" ");
308
309 if a_norm.len() < n && b_norm.len() < n {
310 return if a_norm == b_norm { 1.0 } else { 0.0 };
311 }
312
313 let a_grams: std::collections::HashSet<&str> = (0..=a_norm.len().saturating_sub(n))
314 .filter_map(|i| a_norm.get(i..i + n))
315 .collect();
316 let b_grams: std::collections::HashSet<&str> = (0..=b_norm.len().saturating_sub(n))
317 .filter_map(|i| b_norm.get(i..i + n))
318 .collect();
319
320 if a_grams.is_empty() && b_grams.is_empty() {
321 return 1.0;
322 }
323
324 let intersection = a_grams.intersection(&b_grams).count();
325 let union = a_grams.union(&b_grams).count();
326
327 if union == 0 {
328 0.0
329 } else {
330 intersection as f64 / union as f64
331 }
332}
333
334#[cfg_attr(feature = "wasm", wasm_bindgen)]
338#[derive(Debug, Clone)]
339pub struct DiffResult {
340 added_lines: u32,
341 removed_lines: u32,
342 added_tokens: u32,
343 removed_tokens: u32,
344}
345
346#[cfg_attr(feature = "wasm", wasm_bindgen)]
347impl DiffResult {
348 #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
350 pub fn added_lines(&self) -> u32 {
351 self.added_lines
352 }
353 #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
355 pub fn removed_lines(&self) -> u32 {
356 self.removed_lines
357 }
358 #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
360 pub fn added_tokens(&self) -> u32 {
361 self.added_tokens
362 }
363 #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
365 pub fn removed_tokens(&self) -> u32 {
366 self.removed_tokens
367 }
368}
369
370#[cfg_attr(feature = "wasm", wasm_bindgen)]
376pub fn compute_diff(old_text: &str, new_text: &str) -> DiffResult {
377 let old_lines: Vec<&str> = old_text.lines().collect();
378 let new_lines: Vec<&str> = new_text.lines().collect();
379
380 let n = old_lines.len();
381 let m = new_lines.len();
382
383 let mut dp = vec![vec![0u32; m + 1]; n + 1];
385 for i in 1..=n {
386 for j in 1..=m {
387 if old_lines[i - 1] == new_lines[j - 1] {
388 dp[i][j] = dp[i - 1][j - 1] + 1;
389 } else {
390 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
391 }
392 }
393 }
394
395 let mut removed = Vec::new();
397 let mut added = Vec::new();
398 let mut i = n;
399 let mut j = m;
400
401 while i > 0 || j > 0 {
402 if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
403 i -= 1;
404 j -= 1;
405 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
406 added.push(new_lines[j - 1]);
407 j -= 1;
408 } else {
409 removed.push(old_lines[i - 1]);
410 i -= 1;
411 }
412 }
413
414 let added_tokens: u32 = added.iter().map(|l| calculate_tokens(l)).sum();
415 let removed_tokens: u32 = removed.iter().map(|l| calculate_tokens(l)).sum();
416
417 DiffResult {
418 added_lines: added.len() as u32,
419 removed_lines: removed.len() as u32,
420 added_tokens,
421 removed_tokens,
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn test_base62_encode() {
431 assert_eq!(encode_base62(0), "0");
432 assert_eq!(encode_base62(1), "1");
433 assert_eq!(encode_base62(61), "z");
434 assert_eq!(encode_base62(62), "10");
435 assert_eq!(encode_base62(3844), "100");
436 }
437
438 #[test]
439 fn test_base62_decode() {
440 assert_eq!(decode_base62("0"), 0);
441 assert_eq!(decode_base62("z"), 61);
442 assert_eq!(decode_base62("10"), 62);
443 assert_eq!(decode_base62("100"), 3844);
444 }
445
446 #[test]
447 fn test_base62_roundtrip() {
448 for n in [0, 1, 42, 61, 62, 100, 3844, 999_999, u64::MAX / 2] {
449 assert_eq!(
450 decode_base62(&encode_base62(n)),
451 n,
452 "roundtrip failed for {n}"
453 );
454 }
455 }
456
457 #[test]
458 fn test_hash_content() {
459 assert_eq!(
460 hash_content("hello"),
461 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
462 );
463 assert_eq!(
464 hash_content(""),
465 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
466 );
467 }
468
469 #[test]
470 fn test_calculate_tokens() {
471 assert_eq!(calculate_tokens("Hello, world!"), 4);
472 assert_eq!(calculate_tokens(""), 0);
473 assert_eq!(calculate_tokens("a"), 1);
474 assert_eq!(calculate_tokens("1234"), 1);
475 assert_eq!(calculate_tokens("12345"), 2);
476 }
477
478 #[test]
479 fn test_compression_ratio() {
480 assert_eq!(calculate_compression_ratio(1000, 400), 2.5);
481 assert_eq!(calculate_compression_ratio(100, 100), 1.0);
482 assert_eq!(calculate_compression_ratio(100, 0), 1.0);
483 assert_eq!(calculate_compression_ratio(500, 200), 2.5);
484 }
485
486 #[test]
487 fn test_compress_decompress_roundtrip() {
488 let input = "Hello, world! This is a test of the llmtxt compression.";
489 let compressed = compress(input).expect("compress should succeed");
490 let decompressed = decompress(&compressed).expect("decompress should succeed");
491 assert_eq!(decompressed, input);
492 }
493
494 #[test]
495 fn test_compress_empty() {
496 let compressed = compress("").expect("compress empty should succeed");
497 let decompressed = decompress(&compressed).expect("decompress should succeed");
498 assert_eq!(decompressed, "");
499 }
500
501 #[test]
502 fn test_compute_signature() {
503 let sig = compute_signature(
504 "xK9mP2nQ",
505 "test-agent",
506 "conv_123",
507 1_700_000_000_000.0,
508 "test-secret",
509 );
510 assert_eq!(sig, "650eb9dd6c396a45");
511 }
512
513 #[test]
514 fn test_compute_signature_with_length() {
515 let sig16 = compute_signature_with_length(
516 "xK9mP2nQ",
517 "test-agent",
518 "conv_123",
519 1_700_000_000_000.0,
520 "test-secret",
521 16,
522 );
523 let sig32 = compute_signature_with_length(
524 "xK9mP2nQ",
525 "test-agent",
526 "conv_123",
527 1_700_000_000_000.0,
528 "test-secret",
529 32,
530 );
531 assert_eq!(sig16, "650eb9dd6c396a45");
532 assert_eq!(sig16.len(), 16);
533 assert_eq!(sig32.len(), 32);
534 assert!(sig32.starts_with(&sig16)); }
536
537 #[test]
538 fn test_generate_signed_url_with_path_prefix() {
539 let url = generate_signed_url(&SignedUrlBuildRequest {
540 base_url: "https://api.example.com",
541 path_prefix: "attachments",
542 slug: "xK9mP2nQ",
543 agent_id: "test-agent",
544 conversation_id: "conv_123",
545 expires_at: 1_700_000_000_000,
546 secret: "test-secret",
547 sig_length: 32,
548 })
549 .expect("signed URL should build");
550
551 assert!(url.starts_with("https://api.example.com/attachments/xK9mP2nQ?"));
552 assert!(url.contains("sig="));
553 }
554
555 #[test]
556 fn test_derive_signing_key() {
557 let key = derive_signing_key("sk_live_abc123");
558 assert_eq!(
559 key,
560 "fb5f79640e9ed141d4949ccb36110c7aaf829c56d9870942dd77219a57575372"
561 );
562 }
563
564 #[test]
565 fn test_generate_id_format() {
566 let id = generate_id();
567 assert_eq!(id.len(), 8);
568 assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
569 }
570
571 #[test]
572 fn test_generate_id_uniqueness() {
573 let ids: Vec<String> = (0..100).map(|_| generate_id()).collect();
574 let unique: std::collections::HashSet<&String> = ids.iter().collect();
575 assert_eq!(unique.len(), 100, "generated IDs should be unique");
576 }
577
578 #[test]
579 fn test_compute_org_signature() {
580 let sig = compute_org_signature(
581 "xK9mP2nQ",
582 "test-agent",
583 "conv_123",
584 "org_456",
585 1_700_000_000_000.0,
586 "test-secret",
587 );
588 assert_eq!(sig.len(), 32); let non_org_sig = compute_signature_with_length(
591 "xK9mP2nQ",
592 "test-agent",
593 "conv_123",
594 1_700_000_000_000.0,
595 "test-secret",
596 32,
597 );
598 assert_ne!(sig, non_org_sig);
599 }
600
601 #[test]
602 fn test_compute_org_signature_with_length() {
603 let sig16 = compute_org_signature_with_length(
604 "xK9mP2nQ",
605 "test-agent",
606 "conv_123",
607 "org_456",
608 1_700_000_000_000.0,
609 "test-secret",
610 16,
611 );
612 let sig32 = compute_org_signature_with_length(
613 "xK9mP2nQ",
614 "test-agent",
615 "conv_123",
616 "org_456",
617 1_700_000_000_000.0,
618 "test-secret",
619 32,
620 );
621 assert_eq!(sig16.len(), 16);
622 assert_eq!(sig32.len(), 32);
623 assert!(sig32.starts_with(&sig16));
624 }
625
626 #[test]
627 fn test_is_expired() {
628 assert!(!is_expired(0.0));
629 assert!(is_expired(1.0)); assert!(!is_expired(f64::MAX)); }
632
633 #[test]
634 fn test_verify_signed_url_accepts_32_char_signature_and_path_prefix() {
635 let url = generate_signed_url(&SignedUrlBuildRequest {
636 base_url: "https://api.example.com",
637 path_prefix: "attachments",
638 slug: "xK9mP2nQ",
639 agent_id: "test-agent",
640 conversation_id: "conv_123",
641 expires_at: u64::MAX / 2,
642 secret: "test-secret",
643 sig_length: 32,
644 })
645 .expect("signed URL should build");
646
647 let params = verify_signed_url(&url, "test-secret").expect("signed URL should verify");
648 assert_eq!(params.slug, "xK9mP2nQ");
649 assert_eq!(params.agent_id, "test-agent");
650 assert_eq!(params.conversation_id, "conv_123");
651 }
652
653 #[test]
654 fn test_verify_signed_url_exp_zero_never_expires() {
655 let url = generate_signed_url(&SignedUrlBuildRequest {
656 base_url: "https://api.example.com",
657 path_prefix: "attachments",
658 slug: "xK9mP2nQ",
659 agent_id: "test-agent",
660 conversation_id: "conv_123",
661 expires_at: 0,
662 secret: "test-secret",
663 sig_length: 32,
664 })
665 .expect("signed URL should build");
666
667 let params = verify_signed_url(&url, "test-secret").expect("exp=0 should never expire");
668 assert_eq!(params.slug, "xK9mP2nQ");
669 assert_eq!(params.expires_at, 0);
670 }
671
672 #[test]
673 fn test_compute_diff_identical() {
674 let text = "line 1\nline 2\nline 3";
675 let result = compute_diff(text, text);
676 assert_eq!(result.added_lines(), 0);
677 assert_eq!(result.removed_lines(), 0);
678 assert_eq!(result.added_tokens(), 0);
679 assert_eq!(result.removed_tokens(), 0);
680 }
681
682 #[test]
683 fn test_compute_diff_empty_to_content() {
684 let result = compute_diff("", "line 1\nline 2");
685 assert_eq!(result.added_lines(), 2);
686 assert_eq!(result.removed_lines(), 0);
687 }
688
689 #[test]
690 fn test_compute_diff_content_to_empty() {
691 let result = compute_diff("line 1\nline 2", "");
692 assert_eq!(result.added_lines(), 0);
693 assert_eq!(result.removed_lines(), 2);
694 }
695
696 #[test]
697 fn test_compute_diff_mixed_changes() {
698 let old = "line 1\nline 2\nline 3\nline 4";
699 let new = "line 1\nmodified 2\nline 3\nline 5\nline 6";
700 let result = compute_diff(old, new);
701 assert_eq!(result.removed_lines(), 2);
703 assert_eq!(result.added_lines(), 3);
704 assert!(result.added_tokens() > 0);
705 assert!(result.removed_tokens() > 0);
706 }
707
708 #[test]
709 fn test_compute_diff_tokens() {
710 let old = "short";
711 let new = "this is a much longer replacement line";
712 let result = compute_diff(old, new);
713 assert_eq!(result.removed_lines(), 1);
714 assert_eq!(result.added_lines(), 1);
715 assert_eq!(result.removed_tokens(), calculate_tokens("short"));
716 assert_eq!(
717 result.added_tokens(),
718 calculate_tokens("this is a much longer replacement line")
719 );
720 }
721}