1use super::{
52 ObservedAssignment, OperatorSessionRecord, OperatorSessionStore, OperatorSessionStoreError,
53 SessionId,
54};
55use crate::AgentProviderManifest;
56use async_trait::async_trait;
57use rusqlite::params;
58use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
59use std::path::Path;
60
61const SCHEMA_SQL: &str = "\
62CREATE TABLE IF NOT EXISTS operator_sessions (\
63 sid TEXT PRIMARY KEY, \
64 token_digest TEXT NOT NULL, \
65 capability_manifest_json TEXT, \
66 joined_at_secs INTEGER NOT NULL, \
67 join_desc TEXT, \
68 observed_json TEXT, \
69 observed_total INTEGER NOT NULL DEFAULT 0, \
70 last_access_secs INTEGER NOT NULL DEFAULT 0\
71);\
72";
73
74fn migrate_add_column_if_missing(
84 conn: &rusqlite::Connection,
85 column: &str,
86 decl: &str,
87) -> rusqlite::Result<()> {
88 let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
89 let has_column = stmt
90 .query_map([], |row| row.get::<_, String>(1))?
91 .collect::<Result<Vec<String>, _>>()?
92 .iter()
93 .any(|name| name == column);
94 if !has_column {
95 conn.execute_batch(&format!(
96 "ALTER TABLE operator_sessions ADD COLUMN {column} {decl};"
97 ))?;
98 }
99 Ok(())
100}
101
102fn migrate_drop_column_if_present(
118 conn: &rusqlite::Connection,
119 column: &str,
120) -> rusqlite::Result<()> {
121 let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
122 let has_column = stmt
123 .query_map([], |row| row.get::<_, String>(1))?
124 .collect::<Result<Vec<String>, _>>()?
125 .iter()
126 .any(|name| name == column);
127 if has_column {
128 conn.execute_batch(&format!(
129 "ALTER TABLE operator_sessions DROP COLUMN {column};"
130 ))?;
131 }
132 Ok(())
133}
134
135fn migrate_add_last_access_column(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
153 let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
154 let has_column = stmt
155 .query_map([], |row| row.get::<_, String>(1))?
156 .collect::<Result<Vec<String>, _>>()?
157 .iter()
158 .any(|name| name == "last_access_secs");
159 drop(stmt);
160 if has_column {
161 return Ok(());
162 }
163 conn.execute_batch(
164 "ALTER TABLE operator_sessions ADD COLUMN last_access_secs INTEGER NOT NULL DEFAULT 0;",
165 )?;
166 let now = super::expiry_now() as i64;
167 let stamped = conn.execute(
168 "UPDATE operator_sessions SET last_access_secs = ?1",
169 params![now],
170 )?;
171 if stamped > 0 {
172 tracing::info!(
173 sessions = stamped,
174 "operator session store: added O1's last-access column; the sessions already in \
175 the file are stamped with this upgrade, so each gets a full 24h from here"
176 );
177 }
178 Ok(())
179}
180
181fn init_schema(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
184 conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
185 purge_legacy_plaintext_table(conn)?;
186 conn.execute_batch(SCHEMA_SQL)?;
187 migrate_add_column_if_missing(conn, "join_desc", "TEXT")?;
188 migrate_add_column_if_missing(conn, "observed_json", "TEXT")?;
189 migrate_add_column_if_missing(conn, "observed_total", "INTEGER NOT NULL DEFAULT 0")?;
190 migrate_add_last_access_column(conn)?;
191 migrate_drop_column_if_present(conn, "roles_json")
195}
196
197fn purge_legacy_plaintext_table(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
209 let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
210 let columns = stmt
211 .query_map([], |row| row.get::<_, String>(1))?
212 .collect::<Result<Vec<String>, _>>()?;
213 if columns.iter().any(|name| name == "token") {
214 tracing::warn!(
215 "operator session store: dropping a pre-release table that stored bearer \
216 tokens in plaintext; sessions it held are cleared and must re-login"
217 );
218 conn.execute_batch("DROP TABLE operator_sessions;")?;
219 }
220 Ok(())
221}
222
223fn harden_file_permissions(path: &Path) {
233 #[cfg(unix)]
234 {
235 use std::os::unix::fs::PermissionsExt;
236 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
237 tracing::warn!(
238 path = %path.display(),
239 %error,
240 "operator session store: could not restrict file permissions to 0600"
241 );
242 }
243 }
244 #[cfg(not(unix))]
245 {
246 let _ = path;
247 }
248}
249
250pub struct SqliteOperatorSessionStore {
257 isle: AsyncIsle,
258}
259
260impl SqliteOperatorSessionStore {
261 pub async fn open(
267 path: impl AsRef<Path>,
268 ) -> Result<(Self, AsyncIsleDriver), OperatorSessionStoreError> {
269 let path = path.as_ref().to_path_buf();
270 let (isle, driver) = AsyncIsle::spawn(path.clone(), init_schema)
271 .await
272 .map_err(map_isle_err)?;
273 harden_file_permissions(&path);
276 Ok((Self { isle }, driver))
277 }
278
279 pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), OperatorSessionStoreError> {
281 let (isle, driver) = AsyncIsle::open_in_memory(init_schema)
282 .await
283 .map_err(map_isle_err)?;
284 Ok((Self { isle }, driver))
285 }
286}
287
288fn map_isle_err(e: IsleError) -> OperatorSessionStoreError {
289 OperatorSessionStoreError::Other(format!("sqlite: {e}"))
290}
291
292type SessionRow = (
296 String,
297 String,
298 Option<String>,
299 i64,
300 Option<String>,
301 Option<String>,
302 i64,
303 i64,
304);
305
306const SESSION_SELECT_COLUMNS: &str = "sid, token_digest, capability_manifest_json, \
307 joined_at_secs, join_desc, observed_json, observed_total, last_access_secs";
308
309struct RowDecodeError {
317 raw_sid: String,
320 column: &'static str,
323 detail: String,
325}
326
327fn row_to_record(row: SessionRow) -> Result<OperatorSessionRecord, RowDecodeError> {
328 let (
329 raw_sid,
330 token_digest,
331 capability_manifest_json,
332 joined_at_secs,
333 desc,
334 observed_json,
335 observed_total,
336 last_access_secs,
337 ) = row;
338 let fail = |column: &'static str, detail: String| RowDecodeError {
342 raw_sid: raw_sid.clone(),
343 column,
344 detail,
345 };
346 let sid = SessionId::parse(raw_sid.clone()).map_err(|e| fail("sid", e.to_string()))?;
347 let capability_manifest: Option<AgentProviderManifest> = match capability_manifest_json {
348 Some(text) => Some(
349 serde_json::from_str(&text).map_err(|e| fail("capability_manifest", e.to_string()))?,
350 ),
351 None => None,
352 };
353 let observed: Vec<ObservedAssignment> = match observed_json {
358 Some(text) => serde_json::from_str(&text).map_err(|e| fail("observed", e.to_string()))?,
359 None => Vec::new(),
360 };
361 Ok(OperatorSessionRecord {
362 sid,
363 token_digest,
364 capability_manifest,
365 joined_at_secs: joined_at_secs as u64,
366 last_access_secs: last_access_secs.max(0) as u64,
367 desc,
368 observed,
369 observed_total: observed_total.max(0) as u64,
370 })
371}
372
373#[async_trait]
374impl OperatorSessionStore for SqliteOperatorSessionStore {
375 fn name(&self) -> &str {
376 "sqlite"
377 }
378
379 async fn put(&self, record: OperatorSessionRecord) -> Result<(), OperatorSessionStoreError> {
380 let sid = record.sid.to_string();
381 let token_digest = record.token_digest.clone();
382 let capability_manifest_json = record
383 .capability_manifest
384 .as_ref()
385 .map(serde_json::to_string)
386 .transpose()
387 .map_err(|e| {
388 OperatorSessionStoreError::Other(format!("encode capability_manifest: {e}"))
389 })?;
390 let joined_at_secs = record.joined_at_secs as i64;
391 let desc = record.desc.clone();
392 let observed_json = serde_json::to_string(&record.observed)
393 .map_err(|e| OperatorSessionStoreError::Other(format!("encode observed: {e}")))?;
394 let observed_total = record.observed_total as i64;
395 let last_access_secs = record.last_access_secs as i64;
396
397 self.isle
398 .call(move |conn| {
399 conn.execute(
400 "INSERT OR REPLACE INTO operator_sessions \
401 (sid, token_digest, capability_manifest_json, joined_at_secs, \
402 join_desc, observed_json, observed_total, last_access_secs) \
403 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
404 params![
405 sid,
406 token_digest,
407 capability_manifest_json,
408 joined_at_secs,
409 desc,
410 observed_json,
411 observed_total,
412 last_access_secs
413 ],
414 )
415 })
416 .await
417 .map_err(map_isle_err)?;
418 Ok(())
419 }
420
421 async fn delete(&self, sid: &SessionId) -> Result<(), OperatorSessionStoreError> {
422 let sid_str = sid.to_string();
423 let sid_for_notfound = sid.clone();
424 let n = self
425 .isle
426 .call(move |conn| {
427 conn.execute(
428 "DELETE FROM operator_sessions WHERE sid = ?1",
429 params![sid_str],
430 )
431 })
432 .await
433 .map_err(map_isle_err)?;
434 if n == 0 {
435 Err(OperatorSessionStoreError::NotFound(sid_for_notfound))
436 } else {
437 Ok(())
438 }
439 }
440
441 async fn get(
452 &self,
453 sid: &SessionId,
454 ) -> Result<Option<OperatorSessionRecord>, OperatorSessionStoreError> {
455 let sid_str = sid.to_string();
456 let row = self
457 .isle
458 .call(move |conn| {
459 let mut stmt = conn.prepare(&format!(
460 "SELECT {SESSION_SELECT_COLUMNS} FROM operator_sessions WHERE sid = ?1"
461 ))?;
462 let mut iter = stmt.query_map(params![sid_str], |row| {
463 Ok((
464 row.get::<_, String>(0)?,
465 row.get::<_, String>(1)?,
466 row.get::<_, Option<String>>(2)?,
467 row.get::<_, i64>(3)?,
468 row.get::<_, Option<String>>(4)?,
469 row.get::<_, Option<String>>(5)?,
470 row.get::<_, i64>(6)?,
471 row.get::<_, i64>(7)?,
472 ))
473 })?;
474 iter.next().transpose()
475 })
476 .await
477 .map_err(map_isle_err)?;
478 row.map(|row| {
479 row_to_record(row).map_err(
480 |RowDecodeError {
481 raw_sid,
482 column,
483 detail,
484 }| {
485 OperatorSessionStoreError::Other(format!(
486 "operator session row {raw_sid} will not decode ({column}): {detail}"
487 ))
488 },
489 )
490 })
491 .transpose()
492 }
493
494 async fn list(&self) -> Result<Vec<OperatorSessionRecord>, OperatorSessionStoreError> {
495 let rows = self
496 .isle
497 .call(move |conn| {
498 let mut stmt = conn.prepare(&format!(
501 "SELECT {SESSION_SELECT_COLUMNS} FROM operator_sessions \
502 ORDER BY joined_at_secs ASC, rowid ASC"
503 ))?;
504 let iter = stmt.query_map([], |row| {
505 Ok((
506 row.get::<_, String>(0)?,
507 row.get::<_, String>(1)?,
508 row.get::<_, Option<String>>(2)?,
509 row.get::<_, i64>(3)?,
510 row.get::<_, Option<String>>(4)?,
511 row.get::<_, Option<String>>(5)?,
512 row.get::<_, i64>(6)?,
513 row.get::<_, i64>(7)?,
514 ))
515 })?;
516 let mut out = Vec::new();
517 for r in iter {
518 out.push(r?);
519 }
520 Ok(out)
521 })
522 .await
523 .map_err(map_isle_err)?;
524 let decoded: Vec<OperatorSessionRecord> = rows
529 .into_iter()
530 .filter_map(|row| match row_to_record(row) {
531 Ok(record) => Some(record),
532 Err(RowDecodeError {
533 raw_sid,
534 column,
535 detail,
536 }) => {
537 tracing::warn!(
538 row_sid = %raw_sid,
539 column,
540 detail = %detail,
541 "operator session store: skipping a row that will not decode; \
542 this session is gone and its owner must re-login, but the \
543 remaining sessions are restored"
544 );
545 None
546 }
547 })
548 .collect();
549 let (live, expired) = super::partition_expired(decoded, super::expiry_now(), self.name());
556 for sid in expired {
557 if let Err(error) = self.delete(&sid).await {
561 tracing::warn!(
562 %sid, %error,
563 "operator session store: an expired row could not be deleted; it is \
564 withheld from this restore and will be re-judged at the next boot"
565 );
566 }
567 }
568 Ok(live)
569 }
570}
571
572#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn mk(sid: &str, joined_at_secs: u64) -> OperatorSessionRecord {
587 OperatorSessionRecord {
588 sid: SessionId::parse(sid).unwrap(),
589 token_digest: OperatorSessionRecord::digest_of(&format!("bearer-{sid}")),
590 capability_manifest: None,
591 joined_at_secs,
592 last_access_secs: super::super::expiry_now(),
593 desc: None,
594 observed: Vec::new(),
595 observed_total: 0,
596 }
597 }
598
599 #[tokio::test]
600 async fn put_then_list_orders_by_joined_at() {
601 let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
602 s.put(mk("S-late", 200)).await.unwrap();
603 s.put(mk("S-early", 100)).await.unwrap();
604 let list = s.list().await.unwrap();
605 let sids: Vec<_> = list.iter().map(|r| r.sid.to_string()).collect();
606 assert_eq!(sids, vec!["S-early", "S-late"]);
607 drop(s);
608 driver.shutdown().await.unwrap();
609 }
610
611 #[tokio::test]
612 async fn put_is_upsert() {
613 let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
614 s.put(mk("S-1", 100)).await.unwrap();
615 let mut updated = mk("S-1", 100);
616 updated.token_digest = OperatorSessionRecord::digest_of("rotated");
617 s.put(updated).await.unwrap();
618 let list = s.list().await.unwrap();
619 assert_eq!(list.len(), 1);
620 assert!(list[0].verify_bearer("rotated"));
621 drop(s);
622 driver.shutdown().await.unwrap();
623 }
624
625 #[tokio::test]
626 async fn delete_removes_and_missing_is_not_found() {
627 let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
628 s.put(mk("S-1", 100)).await.unwrap();
629 s.delete(&SessionId::parse("S-1").unwrap()).await.unwrap();
630 assert!(s.list().await.unwrap().is_empty());
631 let err = s
632 .delete(&SessionId::parse("S-1").unwrap())
633 .await
634 .unwrap_err();
635 assert!(matches!(err, OperatorSessionStoreError::NotFound(_)));
636 drop(s);
637 driver.shutdown().await.unwrap();
638 }
639
640 #[tokio::test]
641 async fn manifest_round_trips() {
642 let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
643 let mut rec = mk("S-1", 100);
644 rec.capability_manifest = Some(
645 serde_json::from_value(serde_json::json!({
646 "provider_id": "main-ai-self-report",
647 "capabilities": [{
648 "launch_variant": "mse-coder",
649 "resolved_model": "claude-sonnet-4",
650 "effective_tools": ["Read", "Edit"]
651 }]
652 }))
653 .unwrap(),
654 );
655 s.put(rec.clone()).await.unwrap();
656 let list = s.list().await.unwrap();
657 assert_eq!(list, vec![rec]);
658 drop(s);
659 driver.shutdown().await.unwrap();
660 }
661
662 #[tokio::test]
663 async fn persists_across_reopen() {
664 let dir = tempfile::tempdir().unwrap();
665 let path = dir.path().join("operator_session.db");
666
667 {
668 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
669 s.put(mk("S-keep", 42)).await.unwrap();
670 drop(s);
671 driver.shutdown().await.unwrap();
672 }
673
674 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
675 let list = s.list().await.unwrap();
676 assert_eq!(list.len(), 1);
677 assert_eq!(list[0].sid, SessionId::parse("S-keep").unwrap());
678 assert!(
679 list[0].verify_bearer("bearer-S-keep"),
680 "the restored digest must still verify the original bearer"
681 );
682 drop(s);
683 driver.shutdown().await.unwrap();
684 }
685
686 #[tokio::test]
690 async fn file_holds_the_digest_and_never_the_bearer() {
691 let dir = tempfile::tempdir().unwrap();
692 let path = dir.path().join("operator_session.db");
693 let bearer = "bearer-S-keep";
694
695 {
696 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
697 s.put(mk("S-keep", 42)).await.unwrap();
698 drop(s);
699 driver.shutdown().await.unwrap();
700 }
701
702 let bytes = std::fs::read(&path).expect("read db file");
703 let haystack = String::from_utf8_lossy(&bytes);
704 assert!(
705 !haystack.contains(bearer),
706 "the plaintext bearer must not appear anywhere in the database file"
707 );
708 assert!(
709 haystack.contains(&OperatorSessionRecord::digest_of(bearer)),
710 "the digest is what should be stored"
711 );
712 }
713
714 #[tokio::test]
717 async fn the_kimei_round_trips() {
718 let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
719 let mut rec = mk("S-1", 100);
720 rec.desc = Some("rewriting the seat resolver in mlua-swarm-server".to_string());
721 rec.record_observed(ObservedAssignment::new(
722 "R-1".to_string(),
723 "phase-a-op".to_string(),
724 Some("resolve issue #10".to_string()),
725 Some("/repo".to_string()),
726 Some("/repo/.worktrees/topic".to_string()),
727 Some(serde_json::json!({"issue": 10})),
728 140,
729 ));
730 s.put(rec.clone()).await.unwrap();
731
732 let list = s.list().await.unwrap();
733 assert_eq!(list, vec![rec]);
734 assert_eq!(list[0].last_activity_secs(), 140);
735 drop(s);
736 driver.shutdown().await.unwrap();
737 }
738
739 #[tokio::test]
747 async fn a_pre_kimei_file_is_migrated_not_dropped() {
748 let dir = tempfile::tempdir().unwrap();
749 let path = dir.path().join("operator_session.db");
750
751 {
752 let conn = rusqlite::Connection::open(&path).expect("open pre-記名 db");
753 conn.execute_batch(
754 "CREATE TABLE operator_sessions (\
755 sid TEXT PRIMARY KEY, \
756 token_digest TEXT NOT NULL, \
757 roles_json TEXT NOT NULL, \
758 capability_manifest_json TEXT, \
759 joined_at_secs INTEGER NOT NULL\
760 );",
761 )
762 .expect("create the pre-記名 table");
763 conn.execute(
764 "INSERT INTO operator_sessions VALUES (?1, ?2, ?3, NULL, ?4)",
765 params![
766 "S-old",
767 OperatorSessionRecord::digest_of("bearer-S-old"),
768 r#"["main-ai"]"#,
769 7i64
770 ],
771 )
772 .expect("seed the pre-記名 row");
773 }
774
775 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
776 let list = s.list().await.unwrap();
777 assert_eq!(list.len(), 1, "the row survives the column addition");
778 assert_eq!(list[0].desc, None);
779 assert!(list[0].observed.is_empty());
780 assert_eq!(list[0].observed_total, 0);
781 assert!(list[0].verify_bearer("bearer-S-old"));
782
783 let mut updated = list[0].clone();
790 updated.desc = Some("picked this session back up after a restart".to_string());
791 s.put(updated).await.unwrap();
792 let list = s.list().await.unwrap();
793 assert_eq!(
794 list[0].desc.as_deref(),
795 Some("picked this session back up after a restart")
796 );
797 assert!(
798 !column_names(&path).contains(&"roles_json".to_string()),
799 "the role column is dropped, not carried along unwritten"
800 );
801 drop(s);
802 driver.shutdown().await.unwrap();
803 }
804
805 fn column_names(path: &Path) -> Vec<String> {
808 let conn = rusqlite::Connection::open(path).expect("open db for the column check");
809 let mut stmt = conn
810 .prepare("PRAGMA table_info(operator_sessions)")
811 .expect("prepare table_info");
812 let names = stmt
813 .query_map([], |row| row.get::<_, String>(1))
814 .expect("query table_info")
815 .collect::<Result<Vec<String>, _>>()
816 .expect("collect column names");
817 names
818 }
819
820 #[tokio::test]
823 async fn undecodable_observed_row_is_skipped_not_fatal() {
824 let dir = tempfile::tempdir().unwrap();
825 let path = dir.path().join("operator_session.db");
826 seed_healthy(&path).await;
827 {
828 let conn = rusqlite::Connection::open(&path).expect("open db for the raw seed");
829 conn.execute(
830 "INSERT OR REPLACE INTO operator_sessions \
831 (sid, token_digest, capability_manifest_json, joined_at_secs, \
832 join_desc, observed_json, observed_total) \
833 VALUES (?1, ?2, NULL, ?3, NULL, ?4, 1)",
834 params![
835 "S-bad-observed",
836 OperatorSessionRecord::digest_of("bearer-S-bad-observed"),
837 2i64,
838 r#"[{"run_id": 42}]"#
839 ],
840 )
841 .expect("seed the raw row");
842 }
843
844 let (list, logged) = list_capturing_warnings(&path).await;
845 assert_only_healthy_survived(&list);
846 assert!(
847 logged.contains("S-bad-observed") && logged.contains(r#"column="observed""#),
848 "the warn must name the row and the column that failed: {logged}"
849 );
850 }
851
852 #[tokio::test]
855 async fn legacy_plaintext_table_is_dropped_on_open() {
856 let dir = tempfile::tempdir().unwrap();
857 let path = dir.path().join("operator_session.db");
858
859 {
861 let conn = rusqlite::Connection::open(&path).expect("open legacy db");
862 conn.execute_batch(
863 "CREATE TABLE operator_sessions (\
864 sid TEXT PRIMARY KEY, \
865 token TEXT NOT NULL, \
866 roles_json TEXT NOT NULL, \
867 capability_manifest_json TEXT, \
868 joined_at_secs INTEGER NOT NULL\
869 );",
870 )
871 .expect("create legacy table");
872 conn.execute(
873 "INSERT INTO operator_sessions VALUES (?1, ?2, ?3, NULL, ?4)",
874 params!["S-legacy", "plaintext-bearer", r#"["main-ai"]"#, 1i64],
875 )
876 .expect("seed legacy row");
877 }
878
879 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
880 assert!(
881 s.list().await.unwrap().is_empty(),
882 "the legacy table is dropped, not migrated — its sessions are cleared"
883 );
884 s.put(mk("S-fresh", 10)).await.unwrap();
886 assert_eq!(s.list().await.unwrap().len(), 1);
887 drop(s);
888 driver.shutdown().await.unwrap();
889 }
890
891 #[derive(Clone, Default)]
905 struct CaptureBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
906
907 impl CaptureBuf {
908 fn contents(&self) -> String {
909 String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
910 }
911 }
912
913 impl std::io::Write for CaptureBuf {
914 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
915 self.0.lock().unwrap().extend_from_slice(buf);
916 Ok(buf.len())
917 }
918 fn flush(&mut self) -> std::io::Result<()> {
919 Ok(())
920 }
921 }
922
923 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureBuf {
924 type Writer = Self;
925 fn make_writer(&'a self) -> Self::Writer {
926 self.clone()
927 }
928 }
929
930 fn insert_raw_row(
932 path: &Path,
933 sid: &str,
934 capability_manifest_json: Option<&str>,
935 joined_at_secs: i64,
936 ) {
937 let conn = rusqlite::Connection::open(path).expect("open db for the raw seed");
938 conn.execute(
939 "INSERT OR REPLACE INTO operator_sessions \
940 (sid, token_digest, capability_manifest_json, joined_at_secs) \
941 VALUES (?1, ?2, ?3, ?4)",
942 params![
943 sid,
944 OperatorSessionRecord::digest_of(&format!("bearer-{sid}")),
945 capability_manifest_json,
946 joined_at_secs
947 ],
948 )
949 .expect("seed the raw row");
950 }
951
952 async fn seed_healthy(path: &Path) {
955 let (s, driver) = SqliteOperatorSessionStore::open(path).await.unwrap();
956 s.put(mk("S-healthy", 1)).await.unwrap();
957 drop(s);
958 driver.shutdown().await.unwrap();
959 }
960
961 async fn list_capturing_warnings(path: &Path) -> (Vec<OperatorSessionRecord>, String) {
968 let buf = CaptureBuf::default();
969 let subscriber = tracing_subscriber::fmt()
970 .with_writer(buf.clone())
971 .with_max_level(tracing::Level::WARN)
972 .with_ansi(false)
973 .finish();
974 let guard = tracing::subscriber::set_default(subscriber);
975
976 let (s, driver) = SqliteOperatorSessionStore::open(path).await.unwrap();
977 let list = s
978 .list()
979 .await
980 .expect("one undecodable row must not fail the whole list");
981 drop(s);
982 driver.shutdown().await.unwrap();
983
984 drop(guard);
985 (list, buf.contents())
986 }
987
988 fn assert_only_healthy_survived(list: &[OperatorSessionRecord]) {
989 let sids: Vec<_> = list.iter().map(|r| r.sid.to_string()).collect();
990 assert_eq!(
991 sids,
992 vec!["S-healthy"],
993 "the intact row must survive and the poisoned one must not be returned"
994 );
995 }
996
997 #[tokio::test]
1001 async fn undecodable_sid_row_is_skipped_not_fatal() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let path = dir.path().join("operator_session.db");
1004 seed_healthy(&path).await;
1005 insert_raw_row(&path, "op-legacy-uuid", None, 2);
1006
1007 let (list, logged) = list_capturing_warnings(&path).await;
1008 assert_only_healthy_survived(&list);
1009 assert!(
1010 logged.contains("op-legacy-uuid") && logged.contains(r#"column="sid""#),
1011 "the warn must name the row and the column that failed: {logged}"
1012 );
1013 }
1014
1015 #[tokio::test]
1018 async fn undecodable_capability_manifest_row_is_skipped_not_fatal() {
1019 let dir = tempfile::tempdir().unwrap();
1020 let path = dir.path().join("operator_session.db");
1021 seed_healthy(&path).await;
1022 insert_raw_row(&path, "S-bad-manifest", Some(r#"{"provider_id": 42}"#), 2);
1023
1024 let (list, logged) = list_capturing_warnings(&path).await;
1025 assert_only_healthy_survived(&list);
1026 assert!(
1027 logged.contains("S-bad-manifest") && logged.contains(r#"column="capability_manifest""#),
1028 "the warn must name the row and the column that failed: {logged}"
1029 );
1030 }
1031
1032 #[tokio::test]
1035 async fn several_poisoned_rows_are_all_skipped() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let path = dir.path().join("operator_session.db");
1038 seed_healthy(&path).await;
1039 insert_raw_row(&path, "op-legacy-uuid", None, 3);
1040 insert_raw_row(&path, "S-bad-manifest", Some(r#"{"provider_id": 42}"#), 4);
1041
1042 let (list, _logged) = list_capturing_warnings(&path).await;
1043 assert_only_healthy_survived(&list);
1044 }
1045
1046 #[cfg(unix)]
1049 #[tokio::test]
1050 async fn file_is_owner_only_on_unix() {
1051 use std::os::unix::fs::PermissionsExt;
1052
1053 let dir = tempfile::tempdir().unwrap();
1054 let path = dir.path().join("operator_session.db");
1055 let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
1056 s.put(mk("S-1", 1)).await.unwrap();
1057
1058 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1059 assert_eq!(mode, 0o600, "expected owner-only, got {mode:o}");
1060 drop(s);
1061 driver.shutdown().await.unwrap();
1062 }
1063}