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