Skip to main content

lean_ctx/core/import/
opencode.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! OpenCode session-history import.
4
5use 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
17/// OpenCode's history: `<platform data dir>/opencode/opencode.db`.
18const HISTORY_DIR: &str = "opencode";
19const DATABASE_FILE: &str = "opencode.db";
20/// What a line about the database calls it — never its path.
21const 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/// Imports OpenCode history for the current project.
28#[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/// Imports current-project sessions from an OpenCode SQLite database that
45/// lies inside a named root.
46///
47/// `history_root` is OpenCode's data directory as the caller NAMES it (in
48/// product code `<platform data dir>/opencode`, see [`import`]). `db_path`
49/// is opened only if it resolves — symlinks resolved — inside that root or
50/// inside the request's project, AND the path passes the decision a file
51/// read gets before its file is opened (`admit_history_database`: path
52/// jail, protected store, secret-like path). No authority is derived from
53/// `db_path` itself. The file is then opened ONCE, checked on its descriptor
54/// and read from a private snapshot of that descriptor — the path is never
55/// opened again, so a file exchanged after the decision is not read
56/// (`database::snapshot_history_database`). A refused database is not read:
57/// the result carries the count (`sources_refused`, `sources_skipped`) and no
58/// fact. None of these checks says anything about content — see
59/// `read_database`.
60#[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/// Test-only entry with the former signature, for a fixture database: the
80/// fixture's own directory is named as the root (the test-only counterpart
81/// of `admitted_sources::fixture_root`). Product code names OpenCode's data
82/// directory and has no way to this function.
83#[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
92/// Read the sessions of `project_root` from the snapshot of a verified
93/// OpenCode database. A database is not text: no byte of the file is admitted
94/// as such. It is opened read-only, and the ORIGINAL column values that can
95/// carry content (`message.data`, `part.data`) are admitted as wholes
96/// BEFORE anything is selected from them (`AdmittedValue::admit`, G01): a
97/// withheld or restricted value yields nothing, a masked one is only seen
98/// masked. `importable_part` and the fact builder take admitted values
99/// only. The fact built from a selection is admitted again in `push_fact`;
100/// the session id is kept as a label only if it passes admission unchanged
101/// (`session_label`).
102///
103/// The database is ONE source, all or nothing: everything its rows yield is
104/// held pending and taken over only when every session and row was read
105/// (`staging::read_database`, one policy snapshot for the whole read). A
106/// database that fails anywhere — it does not open, its schema is not the
107/// expected one, a session or history row cannot be stepped or decoded —
108/// counts once (`sources_skipped`, or `sources_refused` when the policy
109/// changed under the read) and contributes nothing else — no fact of an
110/// earlier row, no session count — but one line: `DATABASE_LABEL` and the
111/// fixed sentence of the typed refusal. Not a failure of the read, as
112/// before: a row whose `data` is text but not JSON is skipped with a fixed
113/// sentence, and a value that admission refuses is counted.
114fn 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    // The fallback label is never used: every row is staged under the label
128    // of its own session.
129    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        // The reason, as a fixed sentence chosen by the typed refusal. No
139        // path: this importer has never printed one.
140        Err(refusal) => push_error(&mut result, staging::not_imported(DATABASE_LABEL, refusal)),
141    }
142    finish_result(&mut result, &touched);
143    result
144}
145
146/// Stage what the sessions of `project_root` yield and return their number.
147/// `Err` for any failure of the read — the caller then takes over nothing.
148fn 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    // A statement that does not prepare: the tables or columns are not there.
155    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            // Admission of the originals comes first; one refused original
217            // ends this row (counted once), and nothing of it is selected.
218            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
232/// Select the importable pieces of one admitted part. The result is derived
233/// from admitted values and inherits their verdict (`AdmittedValue::derive`).
234fn 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
380/// Whether `path` resolves inside `project_root`, following symlinks.
381///
382/// Both sides must be canonical or the comparison is meaningless: on macOS the
383/// temp tree is reached through `/var -> /private/var`, and on Windows
384/// `canonicalize` returns a `\\?\` verbatim path. Canonicalising only the
385/// left-hand side made every path look like an escape on those two platforms.
386/// The root is canonicalised by the caller, so this stays a pure comparison.
387fn 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    // Self-defending: `import_from_db` already canonicalises, but this is the
400    // containment check for untrusted paths out of someone else's database, so
401    // it must not depend on a caller having done that. `canonical_or_lexical`
402    // is idempotent on an already-canonical root.
403    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, &current, &other);
509
510        let result = import_from_db(&db, &current);
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    /// A project with the two files the fixture's tool parts name, a second
620    /// project, and OpenCode's data directory — all under one canonical base.
621    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    /// The database is opened only through the named-root rule; inside
634    /// OpenCode's named data directory it is read, and every fact is
635    /// admitted where it is built.
636    #[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, &current, &other);
645
646        let result = super::import_from_history(&history, &db, &current);
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    /// No authority is derived from the database's own path: a `db_path`
667    /// outside every named root is not opened — a typed, content-free
668    /// reason, a count, and no fact.
669    #[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, &current, &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, &current);
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    /// A link inside the named root that points outside it is judged by
693    /// where it resolves: not opened.
694    #[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, &current, &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, &current);
710        assert_eq!(result.sources_refused, 1);
711        assert!(result.facts.is_empty());
712        assert!(result.errors.is_empty(), "{:?}", result.errors);
713    }
714
715    /// An OpenCode database with one session of `project` and the given
716    /// `part.data` documents, in order.
717    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    /// G01, the order. `part.data` carries a classification marker in one
756    /// field and a clean, importable sentence in another. The selector takes
757    /// only the sentence — so the ORIGINAL value is admitted first, and a
758    /// refused original yields no fact. (On the old order — select, then
759    /// admit the fragment — the sentence was imported.)
760    #[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            &current,
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, &current);
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        // All or nothing per database: a row that cannot be decoded (its
798        // `data` is a BLOB) AFTER the two rows above. Nothing of the database
799        // is taken over — the result is that of an import without it plus
800        // its one skip and its one fixed sentence: no fact of the earlier
801        // rows, no session, no counted value.
802        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, &current);
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    /// A key-shaped secret next to an importable sentence: what is selected
829    /// is selected from the masked value, so no fact carries the key.
830    #[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, &current, &[&keyed]);
846
847        let result = super::import_from_history(&history, &db, &current);
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    /// The path decision before the database is opened: under a secret-like
864    /// directory and boundary mode `enforce` it is refused like a file read
865    /// — not opened, counted, no fact.
866    #[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            &current,
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, &current);
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}