1use std::borrow::Cow;
7use std::sync::LazyLock;
8#[allow(unused_imports)]
9use zeph_db::sql;
10
11use regex::Regex;
12use zeph_common::secrets::{BEARER_TOKEN_PATTERN, JWT_PATTERN, PATH_PREFIXES, SECRET_PREFIXES};
13use zeph_common::text::truncate_to_bytes_ref;
14
15use crate::error::MemoryError;
16use crate::store::SqliteStore;
17use crate::types::ConversationId;
18
19static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
23 let alt = SECRET_PREFIXES
24 .iter()
25 .map(|p| regex::escape(p))
26 .collect::<Vec<_>>()
27 .join("|");
28 Regex::new(&format!(r#"(?:{alt})[^\s"'`,;\{{\}}\[\]]*"#)).expect("secret regex")
29});
30
31static PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
32 let alt = PATH_PREFIXES.join("|");
33 Regex::new(&format!(r#"(?:{alt})[^\s"'`,;\{{\}}\[\]]*"#)).expect("path regex")
34});
35
36static BEARER_RE: LazyLock<Regex> =
38 LazyLock::new(|| Regex::new(BEARER_TOKEN_PATTERN).expect("bearer regex"));
39
40static JWT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(JWT_PATTERN).expect("jwt regex"));
43
44pub(crate) fn redact_sensitive(text: &str) -> Cow<'_, str> {
48 let s0: Cow<'_, str> = SECRET_RE.replace_all(text, "[REDACTED]");
52 let s1: Cow<'_, str> = match PATH_RE.replace_all(s0.as_ref(), "[PATH]") {
53 Cow::Borrowed(_) => s0,
54 Cow::Owned(o) => Cow::Owned(o),
55 };
56 let s2: Cow<'_, str> = match BEARER_RE.replace_all(s1.as_ref(), "${1}[REDACTED]") {
58 Cow::Borrowed(_) => s1,
59 Cow::Owned(o) => Cow::Owned(o),
60 };
61 match JWT_RE.replace_all(s2.as_ref(), "[REDACTED_JWT]") {
62 Cow::Borrowed(_) => s2,
63 Cow::Owned(o) => Cow::Owned(o),
64 }
65}
66
67#[derive(Debug, Clone)]
70pub struct CompressionFailurePair {
71 pub id: i64,
72 pub conversation_id: ConversationId,
73 pub compressed_context: String,
74 pub failure_reason: String,
75 pub category: String,
76 pub created_at: String,
77}
78
79const MAX_FIELD_CHARS: usize = 4096;
81
82fn truncate_field(s: &str) -> &str {
83 truncate_to_bytes_ref(s, MAX_FIELD_CHARS)
84}
85
86impl SqliteStore {
87 pub async fn load_compression_guidelines(
98 &self,
99 conversation_id: Option<ConversationId>,
100 ) -> Result<(i64, String), MemoryError> {
101 let row = zeph_db::query_as::<_, (i32, String)>(sql!(
104 "SELECT version, guidelines FROM compression_guidelines \
110 WHERE conversation_id = ? OR conversation_id IS NULL \
111 ORDER BY CASE WHEN conversation_id IS NOT NULL THEN 0 ELSE 1 END, \
112 version DESC \
113 LIMIT 1"
114 ))
115 .bind(conversation_id.map(|c| c.0))
116 .fetch_optional(&self.pool)
117 .await?;
118
119 Ok(row.map_or((0, String::new()), |(version, guidelines)| {
120 (i64::from(version), guidelines)
121 }))
122 }
123
124 pub async fn load_compression_guidelines_meta(
135 &self,
136 conversation_id: Option<ConversationId>,
137 ) -> Result<(i64, String), MemoryError> {
138 let created_at_sel =
143 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
144 let raw = format!(
145 "SELECT version, {created_at_sel} FROM compression_guidelines \
146 WHERE conversation_id = ? OR conversation_id IS NULL \
147 ORDER BY CASE WHEN conversation_id IS NOT NULL THEN 0 ELSE 1 END, \
148 version DESC \
149 LIMIT 1"
150 );
151 let query_sql = zeph_db::rewrite_placeholders(&raw);
152 let row = zeph_db::query_as::<_, (i32, String)>(sqlx::AssertSqlSafe(query_sql))
153 .bind(conversation_id.map(|c| c.0)) .fetch_optional(&self.pool)
155 .await?;
156
157 Ok(row.map_or((0, String::new()), |(version, created_at)| {
158 (i64::from(version), created_at)
159 }))
160 }
161
162 pub async fn save_compression_guidelines(
179 &self,
180 guidelines: &str,
181 token_count: i64,
182 conversation_id: Option<ConversationId>,
183 ) -> Result<i64, MemoryError> {
184 let new_version: i32 = zeph_db::query_scalar(
191 sql!("INSERT INTO compression_guidelines (version, guidelines, token_count, conversation_id) \
192 SELECT COALESCE(MAX(version), 0) + 1, ?, ?, ? \
193 FROM compression_guidelines \
194 RETURNING version"),
195 )
196 .bind(guidelines)
197 .bind(token_count)
198 .bind(conversation_id.map(|c| c.0))
199 .fetch_one(&self.pool)
200 .await?;
201 Ok(i64::from(new_version))
202 }
203
204 pub async fn log_compression_failure(
214 &self,
215 conversation_id: ConversationId,
216 compressed_context: &str,
217 failure_reason: &str,
218 category: &str,
219 ) -> Result<i64, MemoryError> {
220 let ctx = redact_sensitive(compressed_context);
221 let ctx = truncate_field(&ctx);
222 let reason = redact_sensitive(failure_reason);
223 let reason = truncate_field(&reason);
224 let id = zeph_db::query_scalar(sql!(
225 "INSERT INTO compression_failure_pairs \
226 (conversation_id, compressed_context, failure_reason, category) \
227 VALUES (?, ?, ?, ?) RETURNING id"
228 ))
229 .bind(conversation_id.0)
230 .bind(ctx)
231 .bind(reason)
232 .bind(category)
233 .fetch_one(&self.pool)
234 .await?;
235 Ok(id)
236 }
237
238 pub async fn get_unused_failure_pairs(
244 &self,
245 limit: usize,
246 ) -> Result<Vec<CompressionFailurePair>, MemoryError> {
247 let limit = i64::try_from(limit).unwrap_or(i64::MAX);
248 let created_at_sel =
252 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
253 let raw = format!(
254 "SELECT id, conversation_id, compressed_context, failure_reason, category, {created_at_sel} \
255 FROM compression_failure_pairs \
256 WHERE used_in_update = FALSE \
257 ORDER BY compression_failure_pairs.created_at ASC \
258 LIMIT ?"
259 );
260 let query_sql = zeph_db::rewrite_placeholders(&raw);
261 let rows = zeph_db::query_as::<_, (i64, i64, String, String, String, String)>(
262 sqlx::AssertSqlSafe(query_sql),
263 )
264 .bind(limit)
265 .fetch_all(&self.pool)
266 .await?;
267
268 Ok(rows
269 .into_iter()
270 .map(
271 |(id, cid, ctx, reason, category, created_at)| CompressionFailurePair {
272 id,
273 conversation_id: ConversationId(cid),
274 compressed_context: ctx,
275 failure_reason: reason,
276 category,
277 created_at,
278 },
279 )
280 .collect())
281 }
282
283 pub async fn get_unused_failure_pairs_by_category(
291 &self,
292 category: &str,
293 limit: usize,
294 ) -> Result<Vec<CompressionFailurePair>, MemoryError> {
295 let limit = i64::try_from(limit).unwrap_or(i64::MAX);
296 let created_at_sel =
298 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
299 let raw = format!(
300 "SELECT id, conversation_id, compressed_context, failure_reason, category, {created_at_sel} \
301 FROM compression_failure_pairs \
302 WHERE used_in_update = FALSE AND category = ? \
303 ORDER BY compression_failure_pairs.created_at ASC \
304 LIMIT ?"
305 );
306 let query_sql = zeph_db::rewrite_placeholders(&raw);
307 let rows = zeph_db::query_as::<_, (i64, i64, String, String, String, String)>(
308 sqlx::AssertSqlSafe(query_sql),
309 )
310 .bind(category)
311 .bind(limit)
312 .fetch_all(&self.pool)
313 .await?;
314
315 Ok(rows
316 .into_iter()
317 .map(
318 |(id, cid, ctx, reason, cat, created_at)| CompressionFailurePair {
319 id,
320 conversation_id: ConversationId(cid),
321 compressed_context: ctx,
322 failure_reason: reason,
323 category: cat,
324 created_at,
325 },
326 )
327 .collect())
328 }
329
330 pub async fn count_unused_failure_pairs_by_category(
336 &self,
337 category: &str,
338 ) -> Result<i64, MemoryError> {
339 let count = zeph_db::query_scalar(sql!(
340 "SELECT COUNT(*) FROM compression_failure_pairs \
341 WHERE used_in_update = FALSE AND category = ?"
342 ))
343 .bind(category)
344 .fetch_one(&self.pool)
345 .await?;
346 Ok(count)
347 }
348
349 pub async fn load_compression_guidelines_by_category(
358 &self,
359 category: &str,
360 conversation_id: Option<ConversationId>,
361 ) -> Result<(i64, String), MemoryError> {
362 let row = zeph_db::query_as::<_, (i32, String)>(sql!(
365 "SELECT version, guidelines FROM compression_guidelines \
366 WHERE category = ? \
367 AND (conversation_id = ? OR conversation_id IS NULL) \
368 ORDER BY CASE WHEN conversation_id IS NOT NULL THEN 0 ELSE 1 END, \
369 version DESC \
370 LIMIT 1"
371 ))
372 .bind(category)
373 .bind(conversation_id.map(|c| c.0))
374 .fetch_optional(&self.pool)
375 .await?;
376
377 Ok(row.map_or((0, String::new()), |(version, guidelines)| {
378 (i64::from(version), guidelines)
379 }))
380 }
381
382 pub async fn save_compression_guidelines_with_category(
388 &self,
389 guidelines: &str,
390 token_count: i64,
391 category: &str,
392 conversation_id: Option<ConversationId>,
393 ) -> Result<i64, MemoryError> {
394 let new_version: i32 = zeph_db::query_scalar(sql!(
398 "INSERT INTO compression_guidelines \
399 (version, category, guidelines, token_count, conversation_id) \
400 SELECT COALESCE(MAX(version), 0) + 1, ?, ?, ?, ? \
401 FROM compression_guidelines \
402 RETURNING version"
403 ))
404 .bind(category)
405 .bind(guidelines)
406 .bind(token_count)
407 .bind(conversation_id.map(|c| c.0))
408 .fetch_one(&self.pool)
409 .await?;
410 Ok(i64::from(new_version))
411 }
412
413 pub async fn mark_failure_pairs_used(&self, ids: &[i64]) -> Result<(), MemoryError> {
419 if ids.is_empty() {
420 return Ok(());
421 }
422 let placeholders = zeph_db::placeholder_list(1, ids.len());
423 let query = format!(
424 "UPDATE compression_failure_pairs SET used_in_update = TRUE WHERE id IN ({placeholders})"
425 );
426 let mut q = zeph_db::query(sqlx::AssertSqlSafe(query));
427 for id in ids {
428 q = q.bind(id);
429 }
430 q.execute(&self.pool).await?;
431 Ok(())
432 }
433
434 pub async fn count_unused_failure_pairs(&self) -> Result<i64, MemoryError> {
440 let count = zeph_db::query_scalar(sql!(
441 "SELECT COUNT(*) FROM compression_failure_pairs WHERE used_in_update = FALSE"
442 ))
443 .fetch_one(&self.pool)
444 .await?;
445 Ok(count)
446 }
447
448 pub async fn cleanup_old_failure_pairs(&self, keep_recent: usize) -> Result<(), MemoryError> {
458 zeph_db::query(sql!(
460 "DELETE FROM compression_failure_pairs WHERE used_in_update = TRUE"
461 ))
462 .execute(&self.pool)
463 .await?;
464
465 let keep = i64::try_from(keep_recent).unwrap_or(i64::MAX);
467 zeph_db::query(sql!(
468 "DELETE FROM compression_failure_pairs \
469 WHERE used_in_update = FALSE \
470 AND id NOT IN ( \
471 SELECT id FROM compression_failure_pairs \
472 WHERE used_in_update = FALSE \
473 ORDER BY created_at DESC \
474 LIMIT ? \
475 )"
476 ))
477 .bind(keep)
478 .execute(&self.pool)
479 .await?;
480
481 Ok(())
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 async fn make_store() -> SqliteStore {
492 SqliteStore::with_pool_size(":memory:", 1)
493 .await
494 .expect("in-memory SqliteStore")
495 }
496
497 #[tokio::test]
498 async fn load_guidelines_meta_returns_defaults_when_empty() {
499 let store = make_store().await;
500 let (version, created_at) = store.load_compression_guidelines_meta(None).await.unwrap();
501 assert_eq!(version, 0);
502 assert!(created_at.is_empty());
503 }
504
505 #[tokio::test]
506 async fn load_guidelines_meta_returns_version_and_created_at() {
507 let store = make_store().await;
508 store
509 .save_compression_guidelines("keep file paths", 4, None)
510 .await
511 .unwrap();
512 let (version, created_at) = store.load_compression_guidelines_meta(None).await.unwrap();
513 assert_eq!(version, 1);
514 assert!(!created_at.is_empty(), "created_at should be populated");
515 }
516
517 #[tokio::test]
518 async fn load_guidelines_returns_defaults_when_empty() {
519 let store = make_store().await;
520 let (version, text) = store.load_compression_guidelines(None).await.unwrap();
521 assert_eq!(version, 0);
522 assert!(text.is_empty());
523 }
524
525 #[tokio::test]
526 async fn save_and_load_guidelines() {
527 let store = make_store().await;
528 let v1 = store
529 .save_compression_guidelines("always preserve file paths", 4, None)
530 .await
531 .unwrap();
532 assert_eq!(v1, 1);
533 let v2 = store
534 .save_compression_guidelines(
535 "always preserve file paths\nalways preserve errors",
536 8,
537 None,
538 )
539 .await
540 .unwrap();
541 assert_eq!(v2, 2);
542 let (v, text) = store.load_compression_guidelines(None).await.unwrap();
544 assert_eq!(v, 2);
545 assert!(text.contains("errors"));
546 }
547
548 #[tokio::test]
549 async fn load_guidelines_prefers_conversation_specific() {
550 let store = make_store().await;
551 let cid = ConversationId(store.create_conversation().await.unwrap().0);
552 store
553 .save_compression_guidelines("global rule", 2, None)
554 .await
555 .unwrap();
556 store
557 .save_compression_guidelines("conversation rule", 2, Some(cid))
558 .await
559 .unwrap();
560 let (_, text) = store.load_compression_guidelines(Some(cid)).await.unwrap();
561 assert_eq!(text, "conversation rule");
562 }
563
564 #[tokio::test]
565 async fn load_guidelines_falls_back_to_global() {
566 let store = make_store().await;
567 let cid = ConversationId(store.create_conversation().await.unwrap().0);
568 store
569 .save_compression_guidelines("global rule", 2, None)
570 .await
571 .unwrap();
572 let (_, text) = store.load_compression_guidelines(Some(cid)).await.unwrap();
574 assert_eq!(text, "global rule");
575 }
576
577 #[tokio::test]
578 async fn load_guidelines_none_returns_global_only() {
579 let store = make_store().await;
580 let cid = ConversationId(store.create_conversation().await.unwrap().0);
581 store
582 .save_compression_guidelines("conversation rule", 2, Some(cid))
583 .await
584 .unwrap();
585 let (version, text) = store.load_compression_guidelines(None).await.unwrap();
587 assert_eq!(version, 0);
588 assert!(text.is_empty());
589 }
590
591 #[tokio::test]
592 async fn load_guidelines_scope_isolation() {
593 let store = make_store().await;
594 let cid_a = ConversationId(store.create_conversation().await.unwrap().0);
595 let cid_b = ConversationId(store.create_conversation().await.unwrap().0);
596
597 store
599 .save_compression_guidelines("Use bullet points", 1, None)
600 .await
601 .unwrap();
602 store
604 .save_compression_guidelines("Be concise", 2, Some(cid_a))
605 .await
606 .unwrap();
607
608 let (_, text_b) = store
610 .load_compression_guidelines(Some(cid_b))
611 .await
612 .unwrap();
613 assert_eq!(
614 text_b, "Use bullet points",
615 "conversation B must see global guideline"
616 );
617
618 let (_, text_a) = store
620 .load_compression_guidelines(Some(cid_a))
621 .await
622 .unwrap();
623 assert_eq!(
624 text_a, "Be concise",
625 "conversation A must prefer its own guideline over global"
626 );
627
628 let (_, text_global) = store.load_compression_guidelines(None).await.unwrap();
630 assert_eq!(
631 text_global, "Use bullet points",
632 "None scope must see only the global guideline"
633 );
634 }
635
636 #[tokio::test]
637 async fn save_with_nonexistent_conversation_id_fails() {
638 let store = make_store().await;
639 let nonexistent = ConversationId(99999);
640 let result = store
641 .save_compression_guidelines("rule", 1, Some(nonexistent))
642 .await;
643 assert!(
644 result.is_err(),
645 "FK violation expected for nonexistent conversation_id"
646 );
647 }
648
649 #[tokio::test]
650 async fn cascade_delete_removes_conversation_guidelines() {
651 let store = make_store().await;
652 let cid = ConversationId(store.create_conversation().await.unwrap().0);
653 store
654 .save_compression_guidelines("rule", 1, Some(cid))
655 .await
656 .unwrap();
657 zeph_db::query(sql!("DELETE FROM conversations WHERE id = ?"))
659 .bind(cid.0)
660 .execute(store.pool())
661 .await
662 .unwrap();
663 let (version, text) = store.load_compression_guidelines(Some(cid)).await.unwrap();
664 assert_eq!(version, 0);
665 assert!(text.is_empty());
666 }
667
668 #[tokio::test]
669 async fn log_and_count_failure_pairs() {
670 let store = make_store().await;
671 let cid = ConversationId(store.create_conversation().await.unwrap().0);
672 store
673 .log_compression_failure(cid, "compressed ctx", "i don't recall that", "unknown")
674 .await
675 .unwrap();
676 let count = store.count_unused_failure_pairs().await.unwrap();
677 assert_eq!(count, 1);
678 }
679
680 #[tokio::test]
681 async fn get_unused_pairs_sorted_oldest_first() {
682 let store = make_store().await;
683 let cid = ConversationId(store.create_conversation().await.unwrap().0);
684 store
685 .log_compression_failure(cid, "ctx A", "reason A", "unknown")
686 .await
687 .unwrap();
688 store
689 .log_compression_failure(cid, "ctx B", "reason B", "unknown")
690 .await
691 .unwrap();
692 let pairs = store.get_unused_failure_pairs(10).await.unwrap();
693 assert_eq!(pairs.len(), 2);
694 assert_eq!(pairs[0].compressed_context, "ctx A");
695 }
696
697 #[tokio::test]
698 async fn mark_pairs_used_reduces_count() {
699 let store = make_store().await;
700 let cid = ConversationId(store.create_conversation().await.unwrap().0);
701 let id = store
702 .log_compression_failure(cid, "ctx", "reason", "unknown")
703 .await
704 .unwrap();
705 store.mark_failure_pairs_used(&[id]).await.unwrap();
706 let count = store.count_unused_failure_pairs().await.unwrap();
707 assert_eq!(count, 0);
708 }
709
710 #[tokio::test]
711 async fn cleanup_deletes_used_and_trims_unused() {
712 let store = make_store().await;
713 let cid = ConversationId(store.create_conversation().await.unwrap().0);
714 let id1 = store
716 .log_compression_failure(cid, "ctx1", "r1", "tool_output")
717 .await
718 .unwrap();
719 store
720 .log_compression_failure(cid, "ctx2", "r2", "tool_output")
721 .await
722 .unwrap();
723 store
724 .log_compression_failure(cid, "ctx3", "r3", "unknown")
725 .await
726 .unwrap();
727 store.mark_failure_pairs_used(&[id1]).await.unwrap();
728 store.cleanup_old_failure_pairs(1).await.unwrap();
730 let count = store.count_unused_failure_pairs().await.unwrap();
731 assert_eq!(count, 1, "only 1 unused pair should remain");
732 }
733
734 #[test]
735 fn redact_sensitive_api_key_is_redacted() {
736 let result = redact_sensitive("token sk-abc123def456 used for auth");
737 assert!(result.contains("[REDACTED]"), "API key must be redacted");
738 assert!(
739 !result.contains("sk-abc123"),
740 "original key must not appear"
741 );
742 }
743
744 #[test]
745 fn redact_sensitive_plain_text_borrows() {
746 let text = "safe text, no secrets here";
747 let result = redact_sensitive(text);
748 assert!(
749 matches!(result, Cow::Borrowed(_)),
750 "plain text must return Cow::Borrowed (zero-alloc)"
751 );
752 }
753
754 #[test]
755 fn redact_sensitive_filesystem_path_is_redacted() {
756 let result = redact_sensitive("config loaded from /Users/dev/project/config.toml");
757 assert!(
758 result.contains("[PATH]"),
759 "filesystem path must be redacted"
760 );
761 assert!(
762 !result.contains("/Users/dev/"),
763 "original path must not appear"
764 );
765 }
766
767 #[test]
768 fn redact_sensitive_combined_secret_and_path() {
769 let result = redact_sensitive("key sk-abc at /home/user/file");
770 assert!(result.contains("[REDACTED]"), "secret must be redacted");
771 assert!(result.contains("[PATH]"), "path must be redacted");
772 }
773
774 #[tokio::test]
775 async fn log_compression_failure_redacts_secrets() {
776 let store = make_store().await;
777 let cid = ConversationId(store.create_conversation().await.unwrap().0);
778 store
779 .log_compression_failure(
780 cid,
781 "token sk-abc123def456 used for auth",
782 "context lost",
783 "unknown",
784 )
785 .await
786 .unwrap();
787 let pairs = store.get_unused_failure_pairs(10).await.unwrap();
788 assert_eq!(pairs.len(), 1);
789 assert!(
790 pairs[0].compressed_context.contains("[REDACTED]"),
791 "stored context must have redacted secret"
792 );
793 assert!(
794 !pairs[0].compressed_context.contains("sk-abc123"),
795 "stored context must not contain raw secret"
796 );
797 }
798
799 #[tokio::test]
800 async fn log_compression_failure_redacts_paths() {
801 let store = make_store().await;
802 let cid = ConversationId(store.create_conversation().await.unwrap().0);
803 store
804 .log_compression_failure(
805 cid,
806 "/Users/dev/project/config.toml was loaded",
807 "lost",
808 "unknown",
809 )
810 .await
811 .unwrap();
812 let pairs = store.get_unused_failure_pairs(10).await.unwrap();
813 assert!(
814 pairs[0].compressed_context.contains("[PATH]"),
815 "stored context must have redacted path"
816 );
817 assert!(
818 !pairs[0].compressed_context.contains("/Users/dev/"),
819 "stored context must not contain raw path"
820 );
821 }
822
823 #[tokio::test]
824 async fn log_compression_failure_reason_also_redacted() {
825 let store = make_store().await;
826 let cid = ConversationId(store.create_conversation().await.unwrap().0);
827 store
828 .log_compression_failure(
829 cid,
830 "some context",
831 "secret ghp_abc123xyz was leaked",
832 "unknown",
833 )
834 .await
835 .unwrap();
836 let pairs = store.get_unused_failure_pairs(10).await.unwrap();
837 assert!(
838 pairs[0].failure_reason.contains("[REDACTED]"),
839 "failure_reason must also be redacted"
840 );
841 assert!(
842 !pairs[0].failure_reason.contains("ghp_abc123xyz"),
843 "raw secret must not appear in failure_reason"
844 );
845 }
846
847 #[tokio::test]
848 async fn truncate_field_respects_char_boundary() {
849 let s = "а".repeat(5000); let truncated = truncate_field(&s);
851 assert!(truncated.len() <= MAX_FIELD_CHARS);
852 assert!(s.is_char_boundary(truncated.len()));
853 }
854
855 #[tokio::test]
856 async fn unique_constraint_prevents_duplicate_version() {
857 let store = make_store().await;
858 store
860 .save_compression_guidelines("first", 1, None)
861 .await
862 .unwrap();
863 let result = zeph_db::query(
866 sql!("INSERT INTO compression_guidelines (version, guidelines, token_count) VALUES (1, 'dup', 0)"),
867 )
868 .execute(store.pool())
869 .await;
870 assert!(
871 result.is_err(),
872 "duplicate version insert should violate UNIQUE constraint"
873 );
874 }
875
876 #[test]
877 fn redact_sensitive_bearer_token_is_redacted() {
878 let result =
879 redact_sensitive("Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
880 assert!(
881 result.contains("[REDACTED]"),
882 "Bearer token must be redacted: {result}"
883 );
884 assert!(
885 !result.contains("eyJhbGciOiJSUzI1NiJ9"),
886 "raw JWT header must not appear: {result}"
887 );
888 assert!(
889 result.contains("Authorization:"),
890 "header name must be preserved: {result}"
891 );
892 }
893
894 #[test]
895 fn redact_sensitive_bearer_token_case_insensitive() {
896 let result =
897 redact_sensitive("authorization: bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
898 assert!(
899 result.contains("[REDACTED]"),
900 "Bearer header match must be case-insensitive: {result}"
901 );
902 }
903
904 #[test]
905 fn redact_sensitive_standalone_jwt_is_redacted() {
906 let jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.SflKxwRJSMeKKF2";
907 let input = format!("token value: {jwt} was found in logs");
908 let result = redact_sensitive(&input);
909 assert!(
910 result.contains("[REDACTED_JWT]"),
911 "standalone JWT must be replaced with [REDACTED_JWT]: {result}"
912 );
913 assert!(
914 !result.contains("eyJhbGci"),
915 "raw JWT must not appear: {result}"
916 );
917 }
918
919 #[test]
920 fn redact_sensitive_mixed_content_all_redacted() {
921 let input =
922 "key sk-abc123 at /home/user/f with Authorization: Bearer eyJhbG.pay.sig and eyJx.b.c";
923 let result = redact_sensitive(input);
924 assert!(result.contains("[REDACTED]"), "API key must be redacted");
925 assert!(result.contains("[PATH]"), "path must be redacted");
926 assert!(!result.contains("sk-abc123"), "raw API key must not appear");
927 assert!(!result.contains("eyJhbG"), "raw JWT must not appear");
928 }
929
930 #[test]
931 fn redact_sensitive_partial_jwt_not_redacted() {
932 let input = "eyJhbGciOiJSUzI1NiJ9.onlytwoparts";
934 let result = redact_sensitive(input);
935 assert!(
937 !result.contains("[REDACTED_JWT]"),
938 "two-part eyJ string must not be treated as JWT: {result}"
939 );
940 assert!(
942 matches!(result, Cow::Borrowed(_)),
943 "no-match input must return Cow::Borrowed: {result}"
944 );
945 }
946
947 #[test]
948 fn redact_sensitive_alg_none_jwt_empty_signature_redacted() {
949 let input =
951 "token: eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0. was submitted without signature";
952 let result = redact_sensitive(input);
953 assert!(
954 result.contains("[REDACTED_JWT]"),
955 "alg=none JWT with empty signature must be redacted: {result}"
956 );
957 assert!(
958 !result.contains("eyJhbGciOiJub25lIn0"),
959 "raw alg=none JWT header must not appear: {result}"
960 );
961 }
962
963 #[tokio::test]
966 async fn get_unused_pairs_by_category_filters_correctly() {
967 let store = make_store().await;
968 let cid = ConversationId(store.create_conversation().await.unwrap().0);
969 store
970 .log_compression_failure(cid, "tool ctx", "lost tool output", "tool_output")
971 .await
972 .unwrap();
973 store
974 .log_compression_failure(cid, "user ctx", "lost user context", "user_context")
975 .await
976 .unwrap();
977
978 let tool_pairs = store
979 .get_unused_failure_pairs_by_category("tool_output", 10)
980 .await
981 .unwrap();
982 assert_eq!(tool_pairs.len(), 1);
983 assert_eq!(tool_pairs[0].category, "tool_output");
984 assert_eq!(tool_pairs[0].compressed_context, "tool ctx");
985
986 let user_pairs = store
987 .get_unused_failure_pairs_by_category("user_context", 10)
988 .await
989 .unwrap();
990 assert_eq!(user_pairs.len(), 1);
991 assert_eq!(user_pairs[0].category, "user_context");
992
993 let unknown_pairs = store
995 .get_unused_failure_pairs_by_category("assistant_reasoning", 10)
996 .await
997 .unwrap();
998 assert!(unknown_pairs.is_empty());
999 }
1000
1001 #[tokio::test]
1002 async fn count_unused_pairs_by_category_returns_correct_count() {
1003 let store = make_store().await;
1004 let cid = ConversationId(store.create_conversation().await.unwrap().0);
1005 store
1006 .log_compression_failure(cid, "ctx A", "reason", "tool_output")
1007 .await
1008 .unwrap();
1009 store
1010 .log_compression_failure(cid, "ctx B", "reason", "tool_output")
1011 .await
1012 .unwrap();
1013 store
1014 .log_compression_failure(cid, "ctx C", "reason", "user_context")
1015 .await
1016 .unwrap();
1017
1018 let tool_count = store
1019 .count_unused_failure_pairs_by_category("tool_output")
1020 .await
1021 .unwrap();
1022 assert_eq!(tool_count, 2);
1023
1024 let user_count = store
1025 .count_unused_failure_pairs_by_category("user_context")
1026 .await
1027 .unwrap();
1028 assert_eq!(user_count, 1);
1029
1030 let unknown_count = store
1031 .count_unused_failure_pairs_by_category("assistant_reasoning")
1032 .await
1033 .unwrap();
1034 assert_eq!(unknown_count, 0);
1035 }
1036
1037 #[tokio::test]
1038 async fn save_and_load_guidelines_by_category() {
1039 let store = make_store().await;
1040 store
1041 .save_compression_guidelines_with_category(
1042 "preserve tool names",
1043 3,
1044 "tool_output",
1045 None,
1046 )
1047 .await
1048 .unwrap();
1049 store
1050 .save_compression_guidelines_with_category("keep user intent", 3, "user_context", None)
1051 .await
1052 .unwrap();
1053
1054 let (_, tool_text) = store
1055 .load_compression_guidelines_by_category("tool_output", None)
1056 .await
1057 .unwrap();
1058 assert_eq!(tool_text, "preserve tool names");
1059
1060 let (_, user_text) = store
1061 .load_compression_guidelines_by_category("user_context", None)
1062 .await
1063 .unwrap();
1064 assert_eq!(user_text, "keep user intent");
1065 }
1066
1067 #[tokio::test]
1068 async fn load_guidelines_by_category_returns_defaults_when_empty() {
1069 let store = make_store().await;
1070 let (version, text) = store
1072 .load_compression_guidelines_by_category("tool_output", None)
1073 .await
1074 .unwrap();
1075 assert_eq!(version, 0, "version must be 0 when no entries exist");
1076 assert!(text.is_empty(), "text must be empty when no entries exist");
1077 }
1078
1079 #[tokio::test]
1085 async fn concurrent_saves_produce_unique_versions() {
1086 use std::collections::HashSet;
1087 use std::sync::Arc;
1088
1089 let dir = tempfile::tempdir().expect("tempdir");
1090 let db_path = dir.path().join("test.db");
1091 let store = Arc::new(
1092 SqliteStore::with_pool_size(db_path.to_str().expect("utf8 path"), 4)
1093 .await
1094 .expect("file-backed SqliteStore"),
1095 );
1096
1097 let tasks: Vec<_> = (0..8_i64)
1098 .map(|i| {
1099 let s = Arc::clone(&store);
1100 let fut = async move {
1101 s.save_compression_guidelines(&format!("guideline {i}"), i, None)
1102 .await
1103 .expect("concurrent save must succeed")
1104 };
1105 tokio::spawn(fut) })
1107 .collect();
1108
1109 let mut versions = HashSet::new();
1110 for task in tasks {
1111 let v = task.await.expect("task must not panic");
1112 assert!(versions.insert(v), "version {v} appeared more than once");
1113 }
1114 assert_eq!(
1115 versions.len(),
1116 8,
1117 "all 8 saves must produce distinct versions"
1118 );
1119 }
1120}