1use std::collections::HashSet;
6use std::path::{Component, Path, PathBuf};
7
8use rusqlite::params;
9use serde_json::{Map, Value};
10
11use super::database::{DatabaseSnapshot, snapshot_history_database};
12use super::{
13 AdmittedValue, ImportResult, ImportSource, SourceRefusal, finish_result, push_error,
14 session_label, staging,
15};
16
17const HISTORY_DIR: &str = "opencode";
19const DATABASE_FILE: &str = "opencode.db";
20const DATABASE_LABEL: &str = "OpenCode history database";
22
23const MAX_SESSIONS: i64 = 1_000;
24const MAX_SESSION_ROWS_SCANNED: i64 = 10_000;
25const MAX_PARTS: i64 = 10_000;
26
27#[must_use]
29pub fn import() -> ImportResult {
30 let Ok(project_root) = std::env::current_dir() else {
31 return ImportResult::default();
32 };
33 let Some(data_dir) = dirs::data_dir() else {
34 return ImportResult::default();
35 };
36 let history_root = data_dir.join(HISTORY_DIR);
37 import_from_history(
38 &history_root,
39 &history_root.join(DATABASE_FILE),
40 &project_root,
41 )
42}
43
44#[must_use]
61pub fn import_from_history(
62 history_root: &Path,
63 db_path: &Path,
64 project_root: &Path,
65) -> ImportResult {
66 if !db_path.is_file() {
67 return ImportResult::default();
68 }
69 let mut result = ImportResult::default();
70 match snapshot_history_database(history_root, db_path) {
71 Ok(snapshot) => read_database(history_root, &snapshot, project_root, result),
72 Err(refusal) => {
73 result.refuse(refusal);
74 result
75 }
76 }
77}
78
79#[cfg(test)]
84#[must_use]
85pub fn import_from_db(db_path: &Path, project_root: &Path) -> ImportResult {
86 let Some(fixture_root) = db_path.parent() else {
87 return ImportResult::default();
88 };
89 import_from_history(fixture_root, db_path, project_root)
90}
91
92fn read_database(
115 history_root: &Path,
116 snapshot: &DatabaseSnapshot,
117 project_root: &Path,
118 mut result: ImportResult,
119) -> ImportResult {
120 let mut sessions = 0;
121 let (pending, read) = staging::read_database(history_root, snapshot.source_path(), |pending| {
122 sessions = read_sessions(snapshot, project_root, pending)?;
123 Ok(())
124 });
125 let mut touched = HashSet::new();
126 let mut seen = HashSet::new();
127 match pending.settle(
130 read,
131 ImportSource::OpenCode,
132 "opencode:unnamed-session",
133 &mut result,
134 &mut touched,
135 &mut seen,
136 ) {
137 Ok(()) => result.sessions_found = sessions,
138 Err(refusal) => push_error(&mut result, staging::not_imported(DATABASE_LABEL, refusal)),
141 }
142 finish_result(&mut result, &touched);
143 result
144}
145
146fn read_sessions(
149 snapshot: &DatabaseSnapshot,
150 project_root: &Path,
151 pending: &mut staging::Pending,
152) -> Result<usize, SourceRefusal> {
153 let unreadable = |_: rusqlite::Error| SourceRefusal::DatabaseUnreadable;
154 let unsupported = |_: rusqlite::Error| SourceRefusal::UnsupportedSchema;
156 let connection = snapshot.open().map_err(unreadable)?;
157
158 let project_root = canonical_or_lexical(project_root);
159 let mut session_ids = Vec::new();
160 {
161 let mut statement = connection
162 .prepare(
163 "SELECT s.id, p.worktree
164 FROM session s JOIN project p ON p.id = s.project_id
165 ORDER BY s.time_updated DESC, s.id
166 LIMIT ?1",
167 )
168 .map_err(unsupported)?;
169 let rows = statement
170 .query_map([MAX_SESSION_ROWS_SCANNED], |row| {
171 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
172 })
173 .map_err(unreadable)?;
174 for row in rows {
175 let (id, worktree) = row.map_err(unreadable)?;
176 if canonical_or_lexical(Path::new(&worktree)) == project_root {
177 session_ids.push(id);
178 if session_ids.len() >= MAX_SESSIONS as usize {
179 break;
180 }
181 }
182 }
183 }
184
185 let mut parts_remaining = MAX_PARTS;
186 for session_id in &session_ids {
187 if parts_remaining == 0 {
188 break;
189 }
190 let label = session_label(ImportSource::OpenCode, session_id, pending.rules());
191 pending.label(label);
192 let mut statement = connection
193 .prepare(
194 "SELECT m.data, p.data
195 FROM message m JOIN part p ON p.message_id = m.id
196 WHERE m.session_id = ?1
197 ORDER BY p.time_created, p.id
198 LIMIT ?2",
199 )
200 .map_err(unsupported)?;
201 let rows = statement
202 .query_map(params![session_id, parts_remaining], |row| {
203 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
204 })
205 .map_err(unreadable)?;
206 for row in rows {
207 parts_remaining -= 1;
208 let (message, part) = row.map_err(unreadable)?;
209 let (Ok(message), Ok(part)) = (
210 serde_json::from_str::<Value>(&message),
211 serde_json::from_str::<Value>(&part),
212 ) else {
213 pending.error("OpenCode contains a malformed history row".to_owned());
214 continue;
215 };
216 let Some(message) = pending.admit(message) else {
219 continue;
220 };
221 let Some(part) = pending.admit(part) else {
222 continue;
223 };
224 if let Some(safe_part) = importable_part(&message, &part, &project_root) {
225 staging::select(&safe_part, |found| pending.stage(found));
226 }
227 }
228 }
229 Ok(session_ids.len())
230}
231
232fn importable_part(
235 message: &AdmittedValue,
236 admitted_part: &AdmittedValue,
237 project_root: &Path,
238) -> Option<AdmittedValue> {
239 let message = message.value();
240 let part = admitted_part.value();
241 let part_type = part.get("type")?.as_str()?.to_owned();
242 let mut safe_part = Map::new();
243 safe_part.insert("type".to_owned(), Value::String(part_type.clone()));
244
245 match part_type.as_str() {
246 "text" => {
247 let text = safe_text(part.get("text")?.as_str()?)?;
248 safe_part.insert("text".to_owned(), Value::String(text));
249 }
250 "tool" => {
251 let state = part.get("state")?.as_object()?;
252 let mut safe_state = Map::new();
253 if let Some(error) = state
254 .get("error")
255 .and_then(Value::as_str)
256 .and_then(safe_tool_error)
257 {
258 safe_state.insert("error".to_owned(), Value::String(error));
259 }
260 if let Some(input) = state.get("input").and_then(Value::as_object) {
261 let mut safe_input = Map::new();
262 for key in ["file_path", "filePath", "path", "filename", "file_name"] {
263 if let Some(path) = input
264 .get(key)
265 .and_then(Value::as_str)
266 .and_then(|path| project_relative(Path::new(path), project_root))
267 {
268 safe_input.insert(key.to_owned(), Value::String(path));
269 }
270 }
271 if !safe_input.is_empty() {
272 safe_state.insert("input".to_owned(), Value::Object(safe_input));
273 }
274 }
275 if safe_state.is_empty() {
276 return None;
277 }
278 safe_part.insert("state".to_owned(), Value::Object(safe_state));
279 }
280 "patch" => {
281 let files = part
282 .get("files")
283 .and_then(Value::as_array)?
284 .iter()
285 .filter_map(Value::as_str)
286 .filter_map(|path| project_relative(Path::new(path), project_root))
287 .map(|path| serde_json::json!({ "file_path": path }))
288 .collect::<Vec<_>>();
289 if files.is_empty() {
290 return None;
291 }
292 safe_part.insert("files".to_owned(), Value::Array(files));
293 }
294 _ => return None,
295 }
296
297 let mut combined = Map::new();
298 combined.insert(
299 "role".to_owned(),
300 message
301 .get("role")
302 .cloned()
303 .unwrap_or(Value::String("unknown".to_owned())),
304 );
305 combined.insert("part".to_owned(), Value::Object(safe_part));
306 Some(admitted_part.derive(Value::Object(combined)))
307}
308
309fn safe_text(text: &str) -> Option<String> {
310 let safe = text
311 .split(['\n', '.', '!', '?'])
312 .map(str::trim)
313 .filter(|snippet| !contains_sensitive_material(snippet))
314 .collect::<Vec<_>>()
315 .join(". ");
316 (!safe.is_empty()).then_some(safe)
317}
318
319fn safe_tool_error(error: &str) -> Option<String> {
320 let error = error.trim();
321 (!error.is_empty() && !contains_sensitive_material(error)).then(|| error.to_owned())
322}
323
324fn contains_sensitive_material(text: &str) -> bool {
325 let lower = text.to_ascii_lowercase();
326 let has_secret_marker = [
327 "api key",
328 "api-key",
329 "api_key",
330 "apikey",
331 "access token",
332 "access-token",
333 "access_token",
334 "authorization:",
335 "bearer ",
336 "password",
337 "passwd",
338 "private key",
339 "secret",
340 "token ",
341 "token=",
342 "token:",
343 ]
344 .iter()
345 .any(|marker| lower.contains(marker));
346 has_secret_marker || contains_absolute_path(text)
347}
348
349fn contains_absolute_path(text: &str) -> bool {
350 text.char_indices().any(|(index, character)| {
351 if character == '/' {
352 let previous = text[..index].chars().next_back();
353 let next = text[index + 1..].chars().next();
354 let starts_path = previous.is_none_or(|character| {
355 character.is_whitespace()
356 || matches!(character, '`' | '\'' | '"' | '(' | '[' | '{' | '=' | ':')
357 });
358 let is_url = previous == Some(':') && next == Some('/');
359 starts_path && next.is_some_and(|character| character != '/') && !is_url
360 } else {
361 let candidate = &text[index..];
362 looks_like_windows_absolute(candidate)
363 && text[..index].chars().next_back().is_none_or(|character| {
364 character.is_whitespace()
365 || matches!(character, '`' | '\'' | '"' | '(' | '[' | '{' | '=' | ':')
366 })
367 }
368 })
369}
370
371fn looks_like_windows_absolute(path: &str) -> bool {
372 let bytes = path.as_bytes();
373 (bytes.len() >= 3
374 && bytes[0].is_ascii_alphabetic()
375 && bytes[1] == b':'
376 && matches!(bytes[2], b'\\' | b'/'))
377 || path.starts_with("\\\\")
378}
379
380fn existing_path_is_inside(path: &Path, project_root: &Path) -> bool {
388 let mut existing = path;
389 while !existing.exists() {
390 let Some(parent) = existing.parent() else {
391 return false;
392 };
393 existing = parent;
394 }
395 std::fs::canonicalize(existing).is_ok_and(|canonical| canonical.starts_with(project_root))
396}
397
398fn project_relative(path: &Path, project_root: &Path) -> Option<String> {
399 let project_root = &canonical_or_lexical(project_root);
404 let raw = path.to_string_lossy();
405 if cfg!(not(windows)) && looks_like_windows_absolute(&raw) {
406 return None;
407 }
408 let joined = if path.is_absolute() {
409 path.to_path_buf()
410 } else {
411 project_root.join(path)
412 };
413 let normalized = lexical_normalize(&joined)?;
414 if !existing_path_is_inside(&normalized, project_root) {
415 return None;
416 }
417 let relative = normalized.strip_prefix(project_root).ok()?;
418 if relative.as_os_str().is_empty() {
419 return None;
420 }
421 Some(relative.to_string_lossy().replace('\\', "/"))
422}
423
424fn canonical_or_lexical(path: &Path) -> PathBuf {
425 std::fs::canonicalize(path)
426 .ok()
427 .or_else(|| lexical_normalize(path))
428 .unwrap_or_else(|| path.to_path_buf())
429}
430
431fn lexical_normalize(path: &Path) -> Option<PathBuf> {
432 let mut normalized = PathBuf::new();
433 for component in path.components() {
434 match component {
435 Component::CurDir => {}
436 Component::ParentDir => {
437 if !normalized.pop() {
438 return None;
439 }
440 }
441 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
442 Component::RootDir => normalized.push(component.as_os_str()),
443 Component::Normal(part) => normalized.push(part),
444 }
445 }
446 Some(normalized)
447}
448
449#[cfg(test)]
450mod tests {
451 use std::path::Path;
452
453 use rusqlite::Connection;
454 use tempfile::tempdir;
455
456 use super::{contains_sensitive_material, import_from_db, project_relative};
457
458 fn create_fixture(path: &Path, current: &Path, other: &Path) {
459 let db = Connection::open(path).unwrap();
460 db.execute_batch(
461 "CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL);
462 CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, time_updated INTEGER NOT NULL);
463 CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL);
464 CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL);",
465 ).unwrap();
466 db.execute(
467 "INSERT INTO project (id, worktree) VALUES ('p1', ?1), ('p2', ?2)",
468 [current.to_str().unwrap(), other.to_str().unwrap()],
469 )
470 .unwrap();
471 db.execute(
472 "INSERT INTO session (id, project_id, time_updated) VALUES ('s1', 'p1', 2), ('s2', 'p2', 1)",
473 [],
474 )
475 .unwrap();
476 db.execute(
477 "INSERT INTO message (id, session_id, time_created, data) VALUES
478 ('m1', 's1', 1, '{\"role\":\"assistant\"}'), ('m2', 's2', 2, '{\"role\":\"assistant\"}')",
479 [],
480 ).unwrap();
481 db.execute(
482 "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES
483 ('a', 'm1', 's1', 1, '{\"type\":\"text\",\"text\":\"We decided to use a bounded cache.\"}'),
484 ('b', 'm1', 's1', 2, '{\"type\":\"tool\",\"tool\":\"read\",\"state\":{\"status\":\"completed\",\"input\":{\"filePath\":\"src/lib.rs\",\"token\":\"TOP_SECRET\"},\"output\":\"PRIVATE_OUTPUT\"}}'),
485 ('c', 'm1', 's1', 3, '{\"type\":\"tool\",\"tool\":\"bash\",\"state\":{\"status\":\"error\",\"error\":\"Build failed\"}}'),
486 ('d', 'm1', 's1', 4, '{\"type\":\"tool\",\"tool\":\"read\",\"state\":{\"input\":{\"filePath\":\"../secret.txt\"}}}'),
487 ('e', 'm2', 's2', 5, '{\"type\":\"tool\",\"tool\":\"read\",\"state\":{\"input\":{\"filePath\":\"other.txt\"}}}'),
488 ('f', 'm1', 's1', 6, '{\"type\":\"patch\",\"files\":[\"src/patch.rs\",\"../outside.rs\"]}'),
489 ('g', 'm1', 's1', 7, '{\"type\":\"snapshot\",\"path\":\"ignored.txt\"}'),
490 ('h', 'm1', 's1', 8, 'not-json'),
491 ('i', 'm1', 's1', 9, '{\"type\":\"text\",\"text\":\"We decided to use /home/alice/private/key. We chose safe fallback.\"}'),
492 ('j', 'm1', 's1', 10, '{\"type\":\"tool\",\"state\":{\"error\":\"Build failed at /home/alice/private/key token=TOP_SECRET\"}}')",
493 [],
494 ).unwrap();
495 }
496
497 #[test]
498 fn imports_only_current_project_and_filters_unsafe_paths() {
499 let _data = crate::core::data_dir::isolated_data_dir();
500 let temp = tempdir().unwrap();
501 let current = temp.path().join("current");
502 let other = temp.path().join("other");
503 std::fs::create_dir_all(current.join("src")).unwrap();
504 std::fs::write(current.join("src/lib.rs"), "fixture").unwrap();
505 std::fs::write(current.join("src/patch.rs"), "fixture").unwrap();
506 std::fs::create_dir_all(&other).unwrap();
507 let db = temp.path().join("opencode.db");
508 create_fixture(&db, ¤t, &other);
509
510 let result = import_from_db(&db, ¤t);
511
512 assert_eq!(result.sessions_found, 1);
513 assert_eq!(result.files_touched, 2);
514 assert!(
515 result
516 .facts
517 .iter()
518 .any(|fact| fact.value == "Touched file: src/lib.rs")
519 );
520 assert!(
521 result
522 .facts
523 .iter()
524 .any(|fact| fact.category == "imported-decision")
525 );
526 assert!(
527 result
528 .facts
529 .iter()
530 .any(|fact| fact.value == "Observed error: Build failed")
531 );
532 assert!(
533 result
534 .facts
535 .iter()
536 .any(|fact| fact.value == "Touched file: src/patch.rs")
537 );
538 assert!(
539 result
540 .errors
541 .iter()
542 .any(|error| error.contains("malformed"))
543 );
544 assert!(result.facts.iter().all(|fact| {
545 ![
546 "secret.txt",
547 "outside.rs",
548 "other.txt",
549 "ignored.txt",
550 "TOP_SECRET",
551 "PRIVATE_OUTPUT",
552 "/home/alice/private/key",
553 ]
554 .iter()
555 .any(|private| fact.value.contains(private))
556 && fact.source_session == "opencode:s1"
557 && fact.imported_from.as_deref() == Some("opencode")
558 }));
559 }
560
561 #[test]
562 fn sensitive_material_filter_handles_punctuation_and_marker_variants() {
563 for value in [
564 "read `/home/user/key`",
565 "read (/home/user/key)",
566 "path=/home/user/key",
567 "read \"/home/user/key\"",
568 "API key abc",
569 "api-key=abc",
570 "access_token=abc",
571 "token abc",
572 "read `C:\\Users\\user\\key`",
573 ] {
574 assert!(contains_sensitive_material(value), "not rejected: {value}");
575 }
576 for value in [
577 "We decided to use a bounded cache",
578 "See https://example.com/path",
579 "relative/path.rs changed",
580 ] {
581 assert!(
582 !contains_sensitive_material(value),
583 "false positive: {value}"
584 );
585 }
586 }
587
588 #[test]
589 fn path_filter_rejects_windows_and_symlink_escapes() {
590 let temp = tempdir().unwrap();
591 let project = temp.path().join("project");
592 let outside = temp.path().join("outside");
593 std::fs::create_dir_all(&project).unwrap();
594 std::fs::create_dir_all(&outside).unwrap();
595
596 assert_eq!(
597 project_relative(Path::new("src/new.rs"), &project).as_deref(),
598 Some("src/new.rs")
599 );
600 assert!(project_relative(Path::new("../outside/file.rs"), &project).is_none());
601 assert!(project_relative(Path::new(r"C:\outside\file.rs"), &project).is_none());
602 assert!(project_relative(Path::new(r"\\server\share\file.rs"), &project).is_none());
603
604 #[cfg(unix)]
605 {
606 std::os::unix::fs::symlink(&outside, project.join("link")).unwrap();
607 assert!(project_relative(Path::new("link/file.rs"), &project).is_none());
608 }
609 }
610
611 #[test]
612 fn missing_database_is_empty() {
613 let temp = tempdir().unwrap();
614 let result = import_from_db(&temp.path().join("missing.db"), temp.path());
615 assert_eq!(result.sessions_found, 0);
616 assert!(result.errors.is_empty(), "{:?}", result.errors);
617 }
618
619 fn named_history(base: &Path) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
622 let current = base.join("current");
623 let other = base.join("other");
624 std::fs::create_dir_all(current.join("src")).unwrap();
625 std::fs::write(current.join("src/lib.rs"), "fixture").unwrap();
626 std::fs::write(current.join("src/patch.rs"), "fixture").unwrap();
627 std::fs::create_dir_all(&other).unwrap();
628 let history = base.join("data").join("opencode");
629 std::fs::create_dir_all(&history).unwrap();
630 (current, other, history)
631 }
632
633 #[test]
637 fn a_database_inside_the_named_root_is_imported_admitted() {
638 let _data = crate::core::data_dir::isolated_data_dir();
639 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
640 let temp = tempdir().unwrap();
641 let base = temp.path().canonicalize().unwrap();
642 let (current, other, history) = named_history(&base);
643 let db = history.join("opencode.db");
644 create_fixture(&db, ¤t, &other);
645
646 let result = super::import_from_history(&history, &db, ¤t);
647 assert_eq!((result.sources_refused, result.sources_skipped), (0, 0));
648 assert_eq!(result.sessions_found, 1);
649 assert!(
650 result
651 .facts
652 .iter()
653 .any(|fact| fact.value.contains("bounded cache")),
654 "a clean fact is imported"
655 );
656 assert!(
657 result
658 .facts
659 .iter()
660 .all(|fact| fact.source_session == "opencode:s1"),
661 "the session id is the label"
662 );
663 assert_eq!(result.facts_extracted, result.facts.len());
664 }
665
666 #[test]
670 fn a_database_outside_every_named_root_is_not_opened() {
671 let _data = crate::core::data_dir::isolated_data_dir();
672 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
673 let temp = tempdir().unwrap();
674 let base = temp.path().canonicalize().unwrap();
675 let (current, other, history) = named_history(&base);
676 let elsewhere = base.join("elsewhere");
677 std::fs::create_dir_all(&elsewhere).unwrap();
678 let outside = elsewhere.join("opencode.db");
679 create_fixture(&outside, ¤t, &other);
680
681 assert_eq!(
682 crate::core::import::resolve_history_source(&history, &outside),
683 Err(crate::core::import::SourceRefusal::OutsideRoot)
684 );
685 let result = super::import_from_history(&history, &outside, ¤t);
686 assert_eq!(result.sources_refused, 1);
687 assert_eq!(result.sessions_found, 0);
688 assert!(result.facts.is_empty());
689 assert!(result.errors.is_empty(), "{:?}", result.errors);
690 }
691
692 #[cfg(unix)]
695 #[test]
696 fn a_link_inside_the_root_to_a_database_outside_is_not_opened() {
697 let _data = crate::core::data_dir::isolated_data_dir();
698 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
699 let temp = tempdir().unwrap();
700 let base = temp.path().canonicalize().unwrap();
701 let (current, other, history) = named_history(&base);
702 let elsewhere = base.join("elsewhere");
703 std::fs::create_dir_all(&elsewhere).unwrap();
704 let outside = elsewhere.join("real.db");
705 create_fixture(&outside, ¤t, &other);
706 let link = history.join("opencode.db");
707 std::os::unix::fs::symlink(&outside, &link).unwrap();
708
709 let result = super::import_from_history(&history, &link, ¤t);
710 assert_eq!(result.sources_refused, 1);
711 assert!(result.facts.is_empty());
712 assert!(result.errors.is_empty(), "{:?}", result.errors);
713 }
714
715 fn minimal_database(path: &Path, project: &Path, parts: &[&str]) {
718 let db = Connection::open(path).unwrap();
719 db.execute_batch(
720 "CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL);
721 CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, time_updated INTEGER NOT NULL);
722 CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL);
723 CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL);
724 INSERT INTO session (id, project_id, time_updated) VALUES ('s1', 'p1', 1);
725 INSERT INTO message (id, session_id, time_created, data) VALUES ('m1', 's1', 1, '{\"role\":\"assistant\"}');",
726 )
727 .unwrap();
728 db.execute(
729 "INSERT INTO project (id, worktree) VALUES ('p1', ?1)",
730 [project.to_str().unwrap()],
731 )
732 .unwrap();
733 for (index, part) in parts.iter().enumerate() {
734 db.execute(
735 "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?1, 'm1', 's1', ?2, ?3)",
736 rusqlite::params![
737 format!("part-{index}"),
738 i64::try_from(index).unwrap(),
739 part
740 ],
741 )
742 .unwrap();
743 }
744 }
745
746 fn decision_values(result: &crate::core::import::ImportResult) -> Vec<&str> {
747 result
748 .facts
749 .iter()
750 .filter(|fact| fact.category == "imported-decision")
751 .map(|fact| fact.value.as_str())
752 .collect()
753 }
754
755 #[test]
761 fn a_marked_part_yields_no_fact() {
762 let _data = crate::core::data_dir::isolated_data_dir();
763 let temp = tempdir().unwrap();
764 let base = temp.path().canonicalize().unwrap();
765 let (current, _other, history) = named_history(&base);
766 let db = history.join("opencode.db");
767 minimal_database(
768 &db,
769 ¤t,
770 &[
771 r#"{"type":"text","text":"We decided to keep the marked sentence","banner":"CONFIDENTIAL"}"#,
772 r#"{"type":"text","text":"We decided to use a bounded cache"}"#,
773 ],
774 );
775
776 let policy = crate::core::policy::load(
777 "name = \"opencode-original-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
778 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
779 )
780 .unwrap();
781 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
782 let result = super::import_from_history(&history, &db, ¤t);
783
784 assert_eq!(
785 decision_values(&result),
786 vec!["We decided to use a bounded cache"]
787 );
788 assert_eq!(result.facts_skipped, 1, "the marked original, once");
789 assert_eq!((result.sources_refused, result.sources_skipped), (0, 0));
790 assert!(
791 result
792 .facts
793 .iter()
794 .all(|fact| !fact.value.contains("marked sentence"))
795 );
796
797 Connection::open(&db)
803 .unwrap()
804 .execute(
805 "INSERT INTO part (id, message_id, session_id, time_created, data)
806 VALUES ('part-z', 'm1', 's1', 99, X'00')",
807 [],
808 )
809 .unwrap();
810 let result = super::import_from_history(&history, &db, ¤t);
811 assert!(result.facts.is_empty(), "{:?}", decision_values(&result));
812 assert_eq!((result.sources_skipped, result.sources_refused), (1, 0));
813 assert_eq!(
814 (
815 result.sessions_found,
816 result.facts_extracted,
817 result.files_touched,
818 result.facts_skipped
819 ),
820 (0, 0, 0, 0)
821 );
822 assert_eq!(
823 result.errors,
824 vec!["OpenCode history database: history database could not be read; skipped"]
825 );
826 }
827
828 #[test]
831 fn a_keyed_part_never_yields_the_key() {
832 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
833 let _data = crate::core::data_dir::isolated_data_dir();
834 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
835 let temp = tempdir().unwrap();
836 let base = temp.path().canonicalize().unwrap();
837 let (current, _other, history) = named_history(&base);
838 let db = history.join("opencode.db");
839 let keyed = serde_json::json!({
840 "type": "text",
841 "text": "We decided to deploy today",
842 "note": format!("deploy id {AWS_KEY}"),
843 })
844 .to_string();
845 minimal_database(&db, ¤t, &[&keyed]);
846
847 let result = super::import_from_history(&history, &db, ¤t);
848 assert_eq!(result.sources_refused, 0);
849 assert!(
850 result
851 .facts
852 .iter()
853 .all(|fact| !fact.value.contains(AWS_KEY))
854 );
855 if result.facts_skipped == 0 {
856 assert_eq!(decision_values(&result), vec!["We decided to deploy today"]);
857 } else {
858 assert_eq!(result.facts_skipped, 1);
859 assert!(decision_values(&result).is_empty());
860 }
861 }
862
863 #[test]
867 fn a_database_at_a_refused_path_is_not_opened() {
868 let _data = crate::core::data_dir::isolated_data_dir();
869 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
870 let temp = tempdir().unwrap();
871 let base = temp.path().canonicalize().unwrap();
872 let (current, _other, history) = named_history(&base);
873 let secret_dir = history.join(".aws");
874 std::fs::create_dir_all(&secret_dir).unwrap();
875 let db = secret_dir.join("opencode.db");
876 minimal_database(
877 &db,
878 ¤t,
879 &[r#"{"type":"text","text":"We decided to use a bounded cache"}"#],
880 );
881
882 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
883 let result = super::import_from_history(&history, &db, ¤t);
884 crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
885
886 assert_eq!(result.sources_refused, 1);
887 assert_eq!(result.facts_skipped, 0);
888 assert_eq!(result.sessions_found, 0);
889 assert!(result.facts.is_empty());
890 }
891}