From 5a0dfe89005bd4637564701cd9ff571be473a81e Mon Sep 17 00:00:00 2001 From: franzer Date: Tue, 30 Jun 2026 22:34:21 -0600 Subject: [PATCH 1/4] fix: scope per-repo lore sync to the repo's own sessions --- src/cli/commands/sync.rs | 186 ++++++++++++++++++++++----- src/storage/db.rs | 263 ++++++++++++++++++++++++++++++++++----- 2 files changed, 391 insertions(+), 58 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index aae1d57..c4cd9eb 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -297,7 +297,10 @@ fn run_sync(remote: &str) -> Result<()> { let (key, salt) = load_store_credentials(&repo, remote, &keystore)?; let mut db = Database::open_default()?; - let summary = perform_sync(&mut db, &repo, remote, &key, &salt, &machine)?; + // Push only this repo's own sessions so cross-project history is never + // written into (and shared through) this repo's store. + let sessions = db.get_unsynced_sessions_for_repo(&repo)?; + let summary = perform_sync(&mut db, &repo, remote, &key, &salt, &machine, sessions)?; println!( "{} Pulled {}, pushed {}.", @@ -377,7 +380,8 @@ fn quiet_sync_with_keystore( let mut config = Config::load()?; let machine = machine_identity(&mut config)?; let mut db = Database::open_default()?; - perform_sync(&mut db, repo, remote, &key, salt, &machine)?; + let sessions = db.get_unsynced_sessions_for_repo(repo)?; + perform_sync(&mut db, repo, remote, &key, salt, &machine, sessions)?; Ok(()) } @@ -417,6 +421,13 @@ fn configured_remotes(repo: &Path) -> Result> { /// Fetches, merges remote reasoning into the database, then builds and pushes. /// +/// The caller supplies `sessions`, the exact set of local sessions to push into +/// this store. For a per-repo store that is the repo-scoped unsynced set (see +/// [`Database::get_unsynced_sessions_for_repo`]) so cross-project history never +/// leaks into one repo's store; a future global store can pass all unsynced +/// sessions without any change here. Inbound merge is independent of this set: +/// every remote session is pulled regardless of its working directory. +/// /// Retries the whole cycle on a compare-and-swap mismatch (a concurrent local /// sync moved the ref) or a non-fast-forward push (the remote moved between our /// fetch and push), up to [`MAX_SYNC_ATTEMPTS`]. @@ -427,6 +438,7 @@ fn perform_sync( key: &[u8], salt: &[u8], machine: &MachineIdentity, + sessions: Vec, ) -> Result { let mut pulled_total = 0; @@ -460,8 +472,7 @@ fn perform_sync( }; let base = tracking_commit.clone().or(old_local.clone()); - let unsynced = db.get_unsynced_sessions()?; - let mut changes = build_session_changes(db, repo, key, &unsynced)?; + let mut changes = build_session_changes(db, repo, key, &sessions)?; // When rebasing onto the remote tracking commit, carry forward any // already-stored session artifacts that live only in the local ref (for @@ -476,7 +487,7 @@ fn perform_sync( add_meta_changes(db, repo, base.as_deref(), salt, machine, &mut changes)?; let tree = gitref::build_tree(repo, base.as_deref(), &changes)?; - let message = format!("lore: sync {} session(s)", unsynced.len()); + let message = format!("lore: sync {} session(s)", sessions.len()); let commit = gitref::commit_tree(repo, &tree, base.as_deref(), &message)?; // CAS-update the local ref, guarding against a concurrent local sync. @@ -497,12 +508,12 @@ fn perform_sync( } } - let ids: Vec = unsynced.iter().map(|s| s.id).collect(); + let ids: Vec = sessions.iter().map(|s| s.id).collect(); db.mark_sessions_synced(&ids, Utc::now())?; return Ok(SyncSummary { pulled: pulled_total, - pushed: unsynced.len(), + pushed: sessions.len(), }); } @@ -686,7 +697,8 @@ fn run_status(remote: &str, format: OutputFormat) -> Result<()> { }; let set_up = salt.is_some(); - let unsynced = db.unsynced_session_count()?; + // Scope the pending count to this repo so it reflects what a sync will push. + let unsynced = db.unsynced_session_count_for_repo(&repo)?; let last_sync = db.last_sync_time()?; let remote_exists = gitref::remote_ref_exists(&repo, remote, SESSIONS_REF).unwrap_or(false); let local_ref = gitref::resolve_ref(&repo, SESSIONS_REF)?; @@ -1034,8 +1046,25 @@ mod tests { } } + /// Returns the canonical string form of a repo path. + /// + /// A session must be seeded with the same path the scoped selector derives + /// (which canonicalizes the repo, resolving symlinks such as macOS's + /// `/var` -> `/private/var`) so it stays in scope and is actually pushed. + fn repo_dir(repo: &Path) -> String { + repo.canonicalize().unwrap().to_string_lossy().to_string() + } + + /// Returns this repo's unsynced sessions, the set a real sync would push. + fn scoped_unsynced(db: &Database, repo: &Path) -> Vec { + db.get_unsynced_sessions_for_repo(repo).unwrap() + } + /// Seeds a full unsynced session (messages, link, tag, annotation, summary). - fn seed_full_session(db: &mut Database, machine_id: &str) -> Uuid { + /// + /// `working_directory` must be inside the repo under test for the session to + /// be selected by the repo-scoped sync. + fn seed_full_session(db: &mut Database, machine_id: &str, working_directory: &str) -> Uuid { let id = Uuid::new_v4(); let session = Session { id, @@ -1044,7 +1073,7 @@ mod tests { started_at: Utc::now(), ended_at: Some(Utc::now()), model: Some("claude-opus".to_string()), - working_directory: "/proj".to_string(), + working_directory: working_directory.to_string(), git_branch: Some("main".to_string()), source_path: None, message_count: 1, @@ -1060,7 +1089,7 @@ mod tests { content: MessageContent::Text("fix the bug".to_string()), model: None, git_branch: Some("main".to_string()), - cwd: Some("/proj".to_string()), + cwd: Some(working_directory.to_string()), }; // synced_at = None so the session is picked up by get_unsynced_sessions. db.import_session_with_messages(&session, &[message], None) @@ -1163,9 +1192,13 @@ mod tests { create_store(repo_a, "origin", &keystore_a, &ma, passphrase).unwrap(); let (mut db_a, _da) = open_db(); - let session_id = seed_full_session(&mut db_a, "machine-a"); + let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a)); let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap(); - let summary_a = perform_sync(&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma).unwrap(); + let sessions_a = scoped_unsynced(&db_a, repo_a); + let summary_a = perform_sync( + &mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a, + ) + .unwrap(); assert_eq!(summary_a.pushed, 1); // Machine B: join with the same passphrase, then sync (pull). @@ -1183,7 +1216,11 @@ mod tests { let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap(); // Both machines derive the same key from the shared passphrase and salt. assert_eq!(key_a, key_b); - let summary_b = perform_sync(&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb).unwrap(); + let sessions_b = scoped_unsynced(&db_b, repo_b); + let summary_b = perform_sync( + &mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b, + ) + .unwrap(); assert_eq!(summary_b.pulled, 1); // Machine B now has the full reasoning record, including links, tags, @@ -1218,15 +1255,17 @@ mod tests { create_store(repo, "origin", &keystore, &m, "passphrase one two").unwrap(); let (mut db, _dd) = open_db(); - seed_full_session(&mut db, "machine-a"); + seed_full_session(&mut db, "machine-a", &repo_dir(repo)); let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); - perform_sync(&mut db, repo, "origin", &key, &salt, &m).unwrap(); + let sessions = scoped_unsynced(&db, repo); + perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); let sha_first = session_blob_sha(repo); // A second sync with nothing new must not re-encrypt the session, so its // content-addressed blob object stays byte-identical (near-zero growth). - let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m).unwrap(); + let sessions = scoped_unsynced(&db, repo); + let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); assert_eq!(summary.pushed, 0); let sha_second = session_blob_sha(repo); assert_eq!( @@ -1264,9 +1303,13 @@ mod tests { let ma = machine("machine-a", "Machine A"); create_store(repo_a, "origin", &keystore_a, &ma, "the real passphrase").unwrap(); let (mut db_a, _da) = open_db(); - seed_full_session(&mut db_a, "machine-a"); + seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a)); let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap(); - perform_sync(&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma).unwrap(); + let sessions_a = scoped_unsynced(&db_a, repo_a); + perform_sync( + &mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a, + ) + .unwrap(); // Machine B tries to join with the wrong passphrase. let dir_b = tempfile::tempdir().unwrap(); @@ -1391,9 +1434,13 @@ mod tests { create_store(repo_a, "origin", &keystore_a, &ma, passphrase).unwrap(); let (mut db_a, _da) = open_db(); - let session_id = seed_full_session(&mut db_a, "machine-a"); + let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a)); let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap(); - let first = perform_sync(&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma).unwrap(); + let sessions_a = scoped_unsynced(&db_a, repo_a); + let first = perform_sync( + &mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a, + ) + .unwrap(); assert_eq!(first.pushed, 1); // The session is now synced; adding a link locally must re-open it. @@ -1410,7 +1457,11 @@ mod tests { }) .unwrap(); - let second = perform_sync(&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma).unwrap(); + let sessions_a = scoped_unsynced(&db_a, repo_a); + let second = perform_sync( + &mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a, + ) + .unwrap(); assert_eq!( second.pushed, 1, "adding a link must re-export the parent session" @@ -1428,7 +1479,11 @@ mod tests { join_store(repo_b, "origin", &keystore_b, &mb, &salt_b, passphrase).unwrap(); let (mut db_b, _db) = open_db(); let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap(); - perform_sync(&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb).unwrap(); + let sessions_b = scoped_unsynced(&db_b, repo_b); + perform_sync( + &mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b, + ) + .unwrap(); let links = db_b.get_links_by_session(&session_id).unwrap(); assert_eq!(links.len(), 2, "both links must reach the teammate"); @@ -1449,9 +1504,13 @@ mod tests { let ma = machine("machine-a", "Machine A"); create_store(repo_a, "origin", &keystore_a, &ma, "the real passphrase").unwrap(); let (mut db_a, _da) = open_db(); - seed_full_session(&mut db_a, "machine-a"); + seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a)); let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap(); - perform_sync(&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma).unwrap(); + let sessions_a = scoped_unsynced(&db_a, repo_a); + perform_sync( + &mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a, + ) + .unwrap(); // Machine B stores a WRONG key for the same store (same salt, bad pass). let dir_b = tempfile::tempdir().unwrap(); @@ -1469,9 +1528,13 @@ mod tests { // Machine B has a local unsynced session that must not be pushed/marked. let (mut db_b, _db) = open_db(); - let local_id = seed_full_session(&mut db_b, "machine-b"); + let local_id = seed_full_session(&mut db_b, "machine-b", &repo_dir(repo_b)); + let sessions_b = scoped_unsynced(&db_b, repo_b); - let err = perform_sync(&mut db_b, repo_b, "origin", &wrong_key, &salt_b, &mb).unwrap_err(); + let err = perform_sync( + &mut db_b, repo_b, "origin", &wrong_key, &salt_b, &mb, sessions_b, + ) + .unwrap_err(); let msg = err.to_string().to_lowercase(); assert!( msg.contains("passphrase") || msg.contains("decrypt"), @@ -1658,9 +1721,10 @@ mod tests { let setup_commit = git_out(remote_dir.path(), &["rev-parse", SESSIONS_REF]); let (mut db, _dd) = open_db(); - seed_full_session(&mut db, "machine-a"); + seed_full_session(&mut db, "machine-a", &repo_dir(repo)); let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); - perform_sync(&mut db, repo, "origin", &key, &salt, &m).unwrap(); + let sessions = scoped_unsynced(&db, repo); + perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); let session_enc = session_blob_sha_path(repo); @@ -1672,7 +1736,8 @@ mod tests { // Sync again. The already-synced session is not rebuilt, so only the // carry-forward keeps it in the outgoing tree. - perform_sync(&mut db, repo, "origin", &key, &salt, &m).unwrap(); + let sessions = scoped_unsynced(&db, repo); + perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap(); assert!( @@ -1680,4 +1745,65 @@ mod tests { "local-only session must survive a remote rewind" ); } + + #[test] + fn test_sync_only_pushes_in_scope_sessions() { + // A per-repo sync must push only sessions whose working directory is + // inside this repo. A session captured in an unrelated directory must + // stay out of this repo's store (the privacy bug this scoping fixes) and + // remain unsynced. + let (_remote_dir, remote_url) = init_bare_remote(); + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + init_repo(repo); + git(repo, &["remote", "add", "origin", &remote_url]); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + // One session inside a subdirectory of the repo, one in an unrelated dir. + let repo_sub = format!("{}/crate/src", repo_dir(repo)); + let in_scope = seed_full_session(&mut db, "machine-a", &repo_sub); + let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project"); + let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); + + let sessions = scoped_unsynced(&db, repo); + let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); + assert_eq!( + summary.pushed, 1, + "only the in-scope session must be pushed" + ); + + // The store holds exactly the in-scope session's encrypted blob. + let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap(); + let session_blobs = entries.iter().filter(|e| is_session_blob(&e.path)).count(); + assert_eq!(session_blobs, 1, "store must hold exactly one session blob"); + assert!( + entries + .iter() + .any(|e| e.path == format!("sessions/{in_scope}.enc")), + "the in-scope session must be stored" + ); + assert!( + !entries + .iter() + .any(|e| e.path == format!("sessions/{out_of_scope}.enc")), + "the out-of-scope session must not reach this repo's store" + ); + + // The out-of-scope session stays unsynced; the in-scope one is marked + // synced. + let unsynced = db.get_unsynced_sessions().unwrap(); + let unsynced_ids: HashSet = unsynced.iter().map(|s| s.id).collect(); + assert!( + unsynced_ids.contains(&out_of_scope), + "the out-of-scope session must remain unsynced" + ); + assert!( + !unsynced_ids.contains(&in_scope), + "the in-scope session must be marked synced after the push" + ); + } } diff --git a/src/storage/db.rs b/src/storage/db.rs index cd8159e..5a3ce1c 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rusqlite::{params, Connection, OptionalExtension}; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use uuid::Uuid; use super::models::{ @@ -16,6 +16,51 @@ use super::models::{ Summary, Tag, }; +/// Builds the SQL parameters for a path-boundary directory match. +/// +/// Returns `(exact, trailing, like_pattern)` for matching a +/// `working_directory` column against `directory`: the column matches when it +/// equals `exact` (the root with any trailing separator trimmed), equals +/// `trailing` (the root with a trailing separator), or is +/// `LIKE like_pattern ESCAPE '|'` (a descendant path). Anchoring the descendant +/// pattern on the trailing separator is what stops a prefix sibling such as +/// `/home/me/foobar` from matching the directory `/home/me/foo`. The pattern's +/// LIKE metacharacters are escaped so a directory containing `%` or `_` cannot +/// widen the match. +/// +/// Shared by [`Database::find_active_sessions_for_directory`] and +/// [`Database::get_unsynced_sessions_for_repo`] so both scope sessions to a +/// directory the same way. +fn directory_match_params(directory: &str) -> (String, String, String) { + fn escape_like(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '|' => escaped.push_str("||"), + '%' => escaped.push_str("|%"), + '_' => escaped.push_str("|_"), + _ => escaped.push(ch), + } + } + escaped + } + + let separator = std::path::MAIN_SEPARATOR.to_string(); + let mut normalized = directory + .trim_end_matches(std::path::MAIN_SEPARATOR) + .to_string(); + if normalized.is_empty() { + normalized = separator.clone(); + } + let trailing = if normalized == separator { + normalized.clone() + } else { + format!("{normalized}{separator}") + }; + let like_pattern = format!("{}%", escape_like(&trailing)); + (normalized, trailing, like_pattern) +} + /// Parses a UUID from a string, converting errors to rusqlite errors. /// /// Used in row mapping functions where we need to return rusqlite::Result. @@ -1558,6 +1603,41 @@ impl Database { .context("Failed to get unsynced sessions") } + /// Returns unsynced sessions whose working directory is inside `repo_path`. + /// + /// A per-repo lore store must hold only the reasoning history produced in + /// that repository, so an outbound sync scopes its push to sessions whose + /// `working_directory` is the repo root or a descendant of it. Cross-project + /// and cross-tool sessions captured elsewhere are excluded, keeping one + /// repo's store from leaking a user's entire history to teammates. + /// + /// The repo path is canonicalized when possible so it compares against + /// stored paths on the same footing; matching uses the shared path-boundary + /// logic in [`directory_match_params`], so a prefix sibling such as + /// `/x/foobar` never matches the repo `/x/foo`. Results are ordered oldest + /// first to sync in chronological order. + pub fn get_unsynced_sessions_for_repo(&self, repo_path: &Path) -> Result> { + let directory = repo_path + .canonicalize() + .unwrap_or_else(|_| repo_path.to_path_buf()); + let (exact, trailing, like_pattern) = directory_match_params(&directory.to_string_lossy()); + + let mut stmt = self.conn.prepare( + "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id + FROM sessions + WHERE synced_at IS NULL + AND (working_directory = ?1 + OR working_directory = ?2 + OR working_directory LIKE ?3 ESCAPE '|') + ORDER BY started_at ASC" + )?; + + let rows = stmt.query_map(params![exact, trailing, like_pattern], Self::row_to_session)?; + + rows.collect::, _>>() + .context("Failed to get unsynced sessions for repo") + } + /// Returns the count of sessions that have not been synced. pub fn unsynced_session_count(&self) -> Result { let count: i32 = self.conn.query_row( @@ -1568,6 +1648,30 @@ impl Database { Ok(count) } + /// Returns the count of unsynced sessions whose working directory is inside + /// `repo_path`. + /// + /// Repo-scoped counterpart of [`Database::unsynced_session_count`], using the + /// same directory scoping as [`Database::get_unsynced_sessions_for_repo`] so + /// `lore sync status` reports what this repo will actually push. + pub fn unsynced_session_count_for_repo(&self, repo_path: &Path) -> Result { + let directory = repo_path + .canonicalize() + .unwrap_or_else(|_| repo_path.to_path_buf()); + let (exact, trailing, like_pattern) = directory_match_params(&directory.to_string_lossy()); + + let count: i32 = self.conn.query_row( + "SELECT COUNT(*) FROM sessions + WHERE synced_at IS NULL + AND (working_directory = ?1 + OR working_directory = ?2 + OR working_directory LIKE ?3 ESCAPE '|')", + params![exact, trailing, like_pattern], + |row| row.get(0), + )?; + Ok(count) + } + /// Marks sessions as synced with the given timestamp. /// /// Updates the `synced_at` column for all specified session IDs. @@ -1806,34 +1910,9 @@ impl Database { directory: &str, recent_minutes: Option, ) -> Result> { - fn escape_like(input: &str) -> String { - let mut escaped = String::with_capacity(input.len()); - for ch in input.chars() { - match ch { - '|' => escaped.push_str("||"), - '%' => escaped.push_str("|%"), - '_' => escaped.push_str("|_"), - _ => escaped.push(ch), - } - } - escaped - } - let minutes = recent_minutes.unwrap_or(5); let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339(); - let separator = std::path::MAIN_SEPARATOR.to_string(); - let mut normalized = directory - .trim_end_matches(std::path::MAIN_SEPARATOR) - .to_string(); - if normalized.is_empty() { - normalized = separator.clone(); - } - let trailing = if normalized == separator { - normalized.clone() - } else { - format!("{normalized}{separator}") - }; - let like_pattern = format!("{}%", escape_like(&trailing)); + let (exact, trailing, like_pattern) = directory_match_params(directory); let sql = r#" SELECT id, tool, tool_version, started_at, ended_at, model, @@ -1848,7 +1927,7 @@ impl Database { let mut stmt = self.conn.prepare(sql)?; let rows = stmt.query_map( - params![normalized, trailing, like_pattern, cutoff], + params![exact, trailing, like_pattern, cutoff], Self::row_to_session, )?; @@ -4422,6 +4501,134 @@ mod tests { assert!(!found_ids.contains(&session_sibling.id)); } + #[test] + fn test_get_unsynced_sessions_for_repo_includes_root() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let session = create_test_session("claude-code", "/home/user/project", now, None); + db.insert_session(&session).expect("insert session"); + + let found = db + .get_unsynced_sessions_for_repo(Path::new("/home/user/project")) + .expect("scoped unsynced"); + + assert_eq!(found.len(), 1, "session at the repo root must be selected"); + assert_eq!(found[0].id, session.id); + } + + #[test] + fn test_get_unsynced_sessions_for_repo_excludes_unrelated() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let inside = create_test_session("claude-code", "/home/user/project", now, None); + let unrelated = create_test_session("aider", "/home/user/other-project", now, None); + db.insert_session(&inside).expect("insert inside"); + db.insert_session(&unrelated).expect("insert unrelated"); + + let found = db + .get_unsynced_sessions_for_repo(Path::new("/home/user/project")) + .expect("scoped unsynced"); + + let ids: std::collections::HashSet = found.iter().map(|s| s.id).collect(); + assert!( + ids.contains(&inside.id), + "in-scope session must be selected" + ); + assert!( + !ids.contains(&unrelated.id), + "session in an unrelated directory must not be selected" + ); + } + + #[test] + fn test_get_unsynced_sessions_for_repo_includes_nested_subdir() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let nested = create_test_session("claude-code", "/home/user/project/src/deep", now, None); + db.insert_session(&nested).expect("insert nested"); + + let found = db + .get_unsynced_sessions_for_repo(Path::new("/home/user/project")) + .expect("scoped unsynced"); + + assert_eq!( + found.len(), + 1, + "session in a nested subdirectory must be selected" + ); + assert_eq!(found[0].id, nested.id); + } + + #[test] + fn test_get_unsynced_sessions_for_repo_excludes_prefix_sibling() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let root = create_test_session("claude-code", "/home/user/foo", now, None); + let sibling = create_test_session("claude-code", "/home/user/foobar", now, None); + db.insert_session(&root).expect("insert root"); + db.insert_session(&sibling).expect("insert sibling"); + + let found = db + .get_unsynced_sessions_for_repo(Path::new("/home/user/foo")) + .expect("scoped unsynced"); + + let ids: std::collections::HashSet = found.iter().map(|s| s.id).collect(); + assert!( + ids.contains(&root.id), + "the repo root session must be selected" + ); + assert!( + !ids.contains(&sibling.id), + "a prefix-sibling directory must not be matched" + ); + } + + #[test] + fn test_get_unsynced_sessions_for_repo_excludes_already_synced() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let session = create_test_session("claude-code", "/home/user/project", now, None); + db.insert_session(&session).expect("insert session"); + db.mark_sessions_synced(&[session.id], now) + .expect("mark synced"); + + let found = db + .get_unsynced_sessions_for_repo(Path::new("/home/user/project")) + .expect("scoped unsynced"); + + assert!( + found.is_empty(), + "an already-synced in-scope session must not be selected" + ); + } + + #[test] + fn test_unsynced_session_count_for_repo_scopes_to_repo() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let inside = create_test_session("claude-code", "/home/user/project", now, None); + let nested = create_test_session("claude-code", "/home/user/project/src", now, None); + let outside = create_test_session("aider", "/home/user/elsewhere", now, None); + db.insert_session(&inside).expect("insert inside"); + db.insert_session(&nested).expect("insert nested"); + db.insert_session(&outside).expect("insert outside"); + + let count = db + .unsynced_session_count_for_repo(Path::new("/home/user/project")) + .expect("scoped count"); + + assert_eq!( + count, 2, + "count must include only in-scope unsynced sessions" + ); + } + #[test] fn test_find_active_sessions_for_directory_custom_window() { let (db, _dir) = create_test_db(); From 7d6bbf9977143a010392a27e81c610ae106c000a Mon Sep 17 00:00:00 2001 From: franzer Date: Tue, 30 Jun 2026 22:59:38 -0600 Subject: [PATCH 2/4] fix: scope carry-forward to in-repo sessions; match symlinked repo paths --- src/cli/commands/sync.rs | 182 +++++++++++++++++++++++++--- src/storage/db.rs | 256 ++++++++++++++++++++++++++++++++++----- 2 files changed, 396 insertions(+), 42 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index c4cd9eb..98e012f 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -480,7 +480,14 @@ fn perform_sync( // dropped from the outgoing tree. if tracking_commit.is_some() { if let Some(local_commit) = &old_local { - carry_forward_local_sessions(repo, local_commit, &tracking_entries, &mut changes)?; + let in_scope = db.get_session_ids_for_repo(repo)?; + carry_forward_local_sessions( + repo, + local_commit, + &tracking_entries, + &in_scope, + &mut changes, + )?; } } @@ -869,7 +876,22 @@ fn is_session_path(path: &str) -> bool { path.starts_with("sessions/") && (path.ends_with(".enc") || path.ends_with(".meta.json")) } -/// Carries forward local-only session artifacts when rebasing on the remote. +/// Parses the session UUID from a stored session artifact tree path. +/// +/// Handles both artifact forms a session contributes to the tree: +/// `sessions/.enc` and `sessions/.meta.json`. Returns `None` for any +/// path that is not a recognizable session artifact or whose stem is not a +/// UUID, so callers can conservatively skip it. +fn session_uuid_from_path(path: &str) -> Option { + let name = path.strip_prefix("sessions/")?; + let stem = name + .strip_suffix(".meta.json") + .or_else(|| name.strip_suffix(".enc"))?; + Uuid::parse_str(stem).ok() +} + +/// Carries forward in-scope, local-only session artifacts when rebasing on the +/// remote. /// /// When the outgoing tree is based on the remote tracking commit, any session /// entry present in the local ref but absent from the fetched remote tree (a @@ -877,10 +899,18 @@ fn is_session_path(path: &str) -> bool { /// the freshly re-encrypted set) would be dropped. This re-adds each such entry /// at its existing blob SHA. A freshly re-encrypted unsynced session already /// owns its paths in `changes`, so `or_insert` never overwrites those. +/// +/// Only artifacts whose session id is in `in_scope` (the ids of this repo's +/// sessions, from [`Database::get_session_ids_for_repo`]) are carried forward. +/// A local ref can hold out-of-scope sessions when it was written before per-repo +/// scoping existed; carrying those forward would re-push another repo's history +/// and reopen the privacy leak, so any artifact that is out of scope, or whose id +/// cannot be parsed or is not in the local database, is conservatively skipped. fn carry_forward_local_sessions( repo: &Path, local_commit: &str, tracking_entries: &[TreeEntry], + in_scope: &HashSet, changes: &mut BTreeMap, ) -> Result<()> { let tracking_paths: HashSet<&str> = tracking_entries.iter().map(|e| e.path.as_str()).collect(); @@ -889,6 +919,10 @@ fn carry_forward_local_sessions( if !is_session_path(&entry.path) || tracking_paths.contains(entry.path.as_str()) { continue; } + match session_uuid_from_path(&entry.path) { + Some(id) if in_scope.contains(&id) => {} + _ => continue, + } changes.entry(entry.path.clone()).or_insert(entry.sha); } Ok(()) @@ -1557,14 +1591,20 @@ mod tests { let repo = dir.path(); init_repo(repo); + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + let a_enc = format!("sessions/{id_a}.enc"); + let a_meta = format!("sessions/{id_a}.meta.json"); + let b_enc = format!("sessions/{id_b}.enc"); + let enc_sha = gitref::write_blob(repo, b"local-a-enc").unwrap(); let meta_sha = gitref::write_blob(repo, b"local-a-meta").unwrap(); let other_sha = gitref::write_blob(repo, b"remote-b-enc").unwrap(); let mut local = BTreeMap::new(); - local.insert("sessions/a.enc".to_string(), enc_sha.clone()); - local.insert("sessions/a.meta.json".to_string(), meta_sha.clone()); - local.insert("sessions/b.enc".to_string(), other_sha.clone()); + local.insert(a_enc.clone(), enc_sha.clone()); + local.insert(a_meta.clone(), meta_sha.clone()); + local.insert(b_enc.clone(), other_sha.clone()); let local_tree = gitref::build_tree(repo, None, &local).unwrap(); let local_commit = gitref::commit_tree(repo, &local_tree, None, "lore: local").unwrap(); @@ -1572,24 +1612,77 @@ mod tests { let tracking = vec![TreeEntry { mode: "100644".to_string(), sha: other_sha, - path: "sessions/b.enc".to_string(), + path: b_enc.clone(), }]; // A freshly re-encrypted unsynced session owns its own path already. let mut changes = BTreeMap::new(); - changes.insert("sessions/a.enc".to_string(), "fresh-sha".to_string()); + changes.insert(a_enc.clone(), "fresh-sha".to_string()); - carry_forward_local_sessions(repo, &local_commit, &tracking, &mut changes).unwrap(); + // Both sessions are in scope for this repo. + let in_scope: HashSet = [id_a, id_b].into_iter().collect(); + carry_forward_local_sessions(repo, &local_commit, &tracking, &in_scope, &mut changes) + .unwrap(); // a.meta.json (local-only) is carried forward. - assert_eq!(changes.get("sessions/a.meta.json"), Some(&meta_sha)); + assert_eq!(changes.get(&a_meta), Some(&meta_sha)); // a.enc keeps the fresh re-encrypted value (or_insert must not override). - assert_eq!( - changes.get("sessions/a.enc"), - Some(&"fresh-sha".to_string()) - ); + assert_eq!(changes.get(&a_enc), Some(&"fresh-sha".to_string())); // b.enc is in the tracking tree, so it is not re-added. - assert!(!changes.contains_key("sessions/b.enc")); + assert!(!changes.contains_key(&b_enc)); + } + + #[test] + fn test_carry_forward_skips_out_of_scope_sessions() { + // A local-only session artifact whose id is NOT in this repo's scope must + // not be carried forward. This is the privacy regression the scoped + // carry-forward closes: a local ref written before scoping can hold other + // repos' sessions, and re-pushing them would leak that history. + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + init_repo(repo); + + let id_in = Uuid::new_v4(); + let id_out = Uuid::new_v4(); + let in_enc = format!("sessions/{id_in}.enc"); + let in_meta = format!("sessions/{id_in}.meta.json"); + let out_enc = format!("sessions/{id_out}.enc"); + let out_meta = format!("sessions/{id_out}.meta.json"); + + let in_enc_sha = gitref::write_blob(repo, b"in-enc").unwrap(); + let in_meta_sha = gitref::write_blob(repo, b"in-meta").unwrap(); + let out_enc_sha = gitref::write_blob(repo, b"out-enc").unwrap(); + let out_meta_sha = gitref::write_blob(repo, b"out-meta").unwrap(); + + let mut local = BTreeMap::new(); + local.insert(in_enc.clone(), in_enc_sha); + local.insert(in_meta.clone(), in_meta_sha); + local.insert(out_enc.clone(), out_enc_sha); + local.insert(out_meta.clone(), out_meta_sha); + let local_tree = gitref::build_tree(repo, None, &local).unwrap(); + let local_commit = gitref::commit_tree(repo, &local_tree, None, "lore: local").unwrap(); + + // The remote tree is empty (a rewind), so nothing is already present. + let tracking: Vec = Vec::new(); + + // Only the in-scope session's id is in scope. + let in_scope: HashSet = [id_in].into_iter().collect(); + let mut changes = BTreeMap::new(); + carry_forward_local_sessions(repo, &local_commit, &tracking, &in_scope, &mut changes) + .unwrap(); + + // In-scope artifacts are carried forward. + assert!(changes.contains_key(&in_enc), "in-scope .enc carried"); + assert!(changes.contains_key(&in_meta), "in-scope .meta carried"); + // Out-of-scope artifacts are dropped, not re-pushed. + assert!( + !changes.contains_key(&out_enc), + "out-of-scope .enc must not be carried forward" + ); + assert!( + !changes.contains_key(&out_meta), + "out-of-scope .meta must not be carried forward" + ); } #[test] @@ -1806,4 +1899,65 @@ mod tests { "the in-scope session must be marked synced after the push" ); } + + #[test] + fn test_sync_does_not_carry_forward_out_of_scope_local_session() { + // A local ref written before per-repo scoping can hold another repo's + // session artifact. When such an out-of-scope artifact lives only in the + // local ref (absent from the remote), a sync that rebases on the remote + // must NOT carry it forward, or it would re-push another repo's history. + let (_remote_dir, remote_url) = init_bare_remote(); + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + init_repo(repo); + git(repo, &["remote", "add", "origin", &remote_url]); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + let in_scope = seed_full_session(&mut db, "machine-a", &repo_dir(repo)); + // An out-of-scope session exists in the database (so its id is known) but + // its working directory is outside the repo, so it is out of scope. + let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project"); + + // Inject the out-of-scope session's artifacts into the LOCAL ref only, + // simulating a pre-scoping local store that still holds them. + let base = gitref::resolve_ref(repo, SESSIONS_REF).unwrap(); + let leaked_enc = gitref::write_blob(repo, b"leaked-enc").unwrap(); + let leaked_meta = gitref::write_blob(repo, b"leaked-meta").unwrap(); + let mut inject = BTreeMap::new(); + inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc); + inject.insert(format!("sessions/{out_of_scope}.meta.json"), leaked_meta); + let tree = gitref::build_tree(repo, base.as_deref(), &inject).unwrap(); + let commit = gitref::commit_tree(repo, &tree, base.as_deref(), "inject leaked").unwrap(); + gitref::update_ref_checked(repo, SESSIONS_REF, &commit, base.as_deref()).unwrap(); + + let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); + let sessions = scoped_unsynced(&db, repo); + perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); + + let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap(); + // The in-scope session is stored. + assert!( + entries + .iter() + .any(|e| e.path == format!("sessions/{in_scope}.enc")), + "the in-scope session must be stored" + ); + // The out-of-scope local-only artifacts must not survive the sync. + assert!( + !entries + .iter() + .any(|e| e.path == format!("sessions/{out_of_scope}.enc")), + "out-of-scope local-only session must not be carried forward or pushed" + ); + assert!( + !entries + .iter() + .any(|e| e.path == format!("sessions/{out_of_scope}.meta.json")), + "out-of-scope local-only metadata must not be carried forward or pushed" + ); + } } diff --git a/src/storage/db.rs b/src/storage/db.rs index 5a3ce1c..fc79cae 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -61,6 +61,62 @@ fn directory_match_params(directory: &str) -> (String, String, String) { (normalized, trailing, like_pattern) } +/// Builds a SQL predicate and bind values scoping `working_directory` to a repo. +/// +/// A session stores the raw working directory reported by the capturing tool, +/// which may be a symlinked or otherwise non-canonical path (for example a +/// session captured under `/link/repo` when the repo canonicalizes to +/// `/real/repo`). Matching only the canonical repo path would miss such +/// sessions, so this scopes against BOTH the repo path as given and its +/// canonicalized form, ORing the per-variant path-boundary conditions from +/// [`directory_match_params`]. Variants that resolve to identical conditions +/// (the common no-symlink case) are deduplicated. Each variant stays anchored +/// on the root plus a separator, so covering both forms still never matches a +/// path outside either repo root. +/// +/// Returns the predicate text (with `?N` placeholders) and the flat bind vector +/// in placeholder order, for use with [`rusqlite::params_from_iter`]. Callers +/// wrap the predicate in their own parentheses when combining it with other +/// conditions. +/// +/// Residual limitation: layouts where the repo is reached through more than the +/// as-given and fully canonicalized paths (for example a chain of distinct +/// symlinks, or sessions captured under an intermediate symlink target) are not +/// covered; only those two forms are matched. +fn repo_scope_predicate(repo_path: &Path) -> (String, Vec) { + let mut variants: Vec<(String, String, String)> = Vec::new(); + + let mut add_variant = |dir: &str| { + let params = directory_match_params(dir); + if !variants.contains(¶ms) { + variants.push(params); + } + }; + + add_variant(&repo_path.to_string_lossy()); + if let Ok(canonical) = repo_path.canonicalize() { + add_variant(&canonical.to_string_lossy()); + } + + let mut clauses = Vec::with_capacity(variants.len()); + let mut binds = Vec::with_capacity(variants.len() * 3); + let mut next = 1; + for (exact, trailing, like_pattern) in variants { + clauses.push(format!( + "(working_directory = ?{} OR working_directory = ?{} OR working_directory LIKE ?{} ESCAPE '|')", + next, + next + 1, + next + 2 + )); + binds.push(exact); + binds.push(trailing); + binds.push(like_pattern); + next += 3; + } + + (clauses.join(" OR "), binds) +} + /// Parses a UUID from a string, converting errors to rusqlite errors. /// /// Used in row mapping functions where we need to return rusqlite::Result. @@ -1611,33 +1667,54 @@ impl Database { /// and cross-tool sessions captured elsewhere are excluded, keeping one /// repo's store from leaking a user's entire history to teammates. /// - /// The repo path is canonicalized when possible so it compares against - /// stored paths on the same footing; matching uses the shared path-boundary - /// logic in [`directory_match_params`], so a prefix sibling such as - /// `/x/foobar` never matches the repo `/x/foo`. Results are ordered oldest - /// first to sync in chronological order. + /// Matching uses the shared repo-scoping predicate (see + /// [`repo_scope_predicate`]), which covers both the repo path as given and + /// its canonicalized form so a session captured under a symlinked path still + /// syncs, while a prefix sibling such as `/x/foobar` never matches the repo + /// `/x/foo`. Results are ordered oldest first to sync in chronological order. pub fn get_unsynced_sessions_for_repo(&self, repo_path: &Path) -> Result> { - let directory = repo_path - .canonicalize() - .unwrap_or_else(|_| repo_path.to_path_buf()); - let (exact, trailing, like_pattern) = directory_match_params(&directory.to_string_lossy()); - - let mut stmt = self.conn.prepare( + let (predicate, binds) = repo_scope_predicate(repo_path); + let sql = format!( "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE synced_at IS NULL - AND (working_directory = ?1 - OR working_directory = ?2 - OR working_directory LIKE ?3 ESCAPE '|') + AND ({predicate}) ORDER BY started_at ASC" - )?; + ); - let rows = stmt.query_map(params![exact, trailing, like_pattern], Self::row_to_session)?; + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(binds.iter()), + Self::row_to_session, + )?; rows.collect::, _>>() .context("Failed to get unsynced sessions for repo") } + /// Returns the ids of ALL sessions (regardless of `synced_at`) whose working + /// directory is inside `repo_path`. + /// + /// Unlike [`Database::get_unsynced_sessions_for_repo`], this ignores the + /// synced state: it answers "which session ids belong to this repo", which + /// the outbound sync uses to decide whether an already-stored, local-only + /// session artifact is in scope to carry forward. It shares the exact same + /// directory scoping (see [`repo_scope_predicate`]) so the two agree on what + /// "in this repo" means. + pub fn get_session_ids_for_repo(&self, repo_path: &Path) -> Result> { + let (predicate, binds) = repo_scope_predicate(repo_path); + let sql = format!("SELECT id FROM sessions WHERE {predicate}"); + + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(binds.iter()), |row| { + let id: String = row.get(0)?; + parse_uuid(&id) + })?; + + rows.collect::>>() + .context("Failed to get session ids for repo") + } + /// Returns the count of sessions that have not been synced. pub fn unsynced_session_count(&self) -> Result { let count: i32 = self.conn.query_row( @@ -1655,20 +1732,18 @@ impl Database { /// same directory scoping as [`Database::get_unsynced_sessions_for_repo`] so /// `lore sync status` reports what this repo will actually push. pub fn unsynced_session_count_for_repo(&self, repo_path: &Path) -> Result { - let directory = repo_path - .canonicalize() - .unwrap_or_else(|_| repo_path.to_path_buf()); - let (exact, trailing, like_pattern) = directory_match_params(&directory.to_string_lossy()); - - let count: i32 = self.conn.query_row( + let (predicate, binds) = repo_scope_predicate(repo_path); + let sql = format!( "SELECT COUNT(*) FROM sessions WHERE synced_at IS NULL - AND (working_directory = ?1 - OR working_directory = ?2 - OR working_directory LIKE ?3 ESCAPE '|')", - params![exact, trailing, like_pattern], - |row| row.get(0), - )?; + AND ({predicate})" + ); + + let count: i32 = + self.conn + .query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| { + row.get(0) + })?; Ok(count) } @@ -4629,6 +4704,131 @@ mod tests { ); } + #[test] + fn test_get_session_ids_for_repo_includes_synced_and_excludes_unrelated() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let unsynced = create_test_session("claude-code", "/home/user/project", now, None); + let synced = create_test_session("claude-code", "/home/user/project/src", now, None); + let outside = create_test_session("aider", "/home/user/other", now, None); + db.insert_session(&unsynced).expect("insert unsynced"); + db.insert_session(&synced).expect("insert synced"); + db.insert_session(&outside).expect("insert outside"); + db.mark_sessions_synced(&[synced.id], now) + .expect("mark synced"); + + let ids = db + .get_session_ids_for_repo(Path::new("/home/user/project")) + .expect("scoped ids"); + + assert!( + ids.contains(&unsynced.id), + "an unsynced in-scope id must be included" + ); + assert!( + ids.contains(&synced.id), + "an already-synced in-scope id must still be included (scope ignores sync state)" + ); + assert!( + !ids.contains(&outside.id), + "an out-of-scope id must be excluded" + ); + } + + // Fix 2: sessions captured under a non-canonical (symlink) path must still + // be scoped to the repo. macOS temp dirs live under a symlinked `/var`, so + // this also guards the canonical-path branch there. + #[cfg(unix)] + #[test] + fn test_get_unsynced_sessions_for_repo_matches_both_path_variants() { + use std::os::unix::fs::symlink; + + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + let base = tempdir().expect("tempdir"); + let real = base.path().join("real"); + std::fs::create_dir(&real).expect("create real dir"); + let link = base.path().join("link"); + symlink(&real, &link).expect("create symlink"); + + // One session captured under the symlinked (as-given) path, one under the + // fully canonicalized path. A repo reported as the symlink form must + // select both, since either variant may appear in a stored session. + let via_link = create_test_session("claude-code", &link.to_string_lossy(), now, None); + let canonical = real.canonicalize().expect("canonicalize real"); + let via_real = create_test_session("aider", &canonical.to_string_lossy(), now, None); + db.insert_session(&via_link).expect("insert via link"); + db.insert_session(&via_real).expect("insert via real"); + + let found = db + .get_unsynced_sessions_for_repo(&link) + .expect("scoped unsynced"); + let ids: std::collections::HashSet = found.iter().map(|s| s.id).collect(); + + assert!( + ids.contains(&via_link.id), + "a session captured under the symlinked repo path must match" + ); + assert!( + ids.contains(&via_real.id), + "a session captured under the canonicalized repo path must match" + ); + } + + // Fix 3: LIKE metacharacters in the repo path must not widen the match. + // Regression guard for the ESCAPE '|' logic in directory_match_params. + #[test] + fn test_repo_scope_escapes_like_metacharacters() { + let (db, _dir) = create_test_db(); + let now = Utc::now(); + + // A repo path packed with LIKE metacharacters: '_' (single-char + // wildcard), '%' (multi-char wildcard), and '|' (the ESCAPE character + // itself). + let repo = "/home/user/w_%|k"; + + let root = create_test_session("claude-code", repo, now, None); + let nested = create_test_session("claude-code", "/home/user/w_%|k/deep/file", now, None); + // Decoys a selector with broken escaping would wrongly match: each + // replaces one metacharacter position with an arbitrary run so it only + // matches if that metacharacter is treated as a wildcard. + let underscore_decoy = create_test_session("aider", "/home/user/wX%|k/x", now, None); + let percent_decoy = create_test_session("aider", "/home/user/w_ANY|k/y", now, None); + for s in [&root, &nested, &underscore_decoy, &percent_decoy] { + db.insert_session(s).expect("insert session"); + } + + let found = db + .get_unsynced_sessions_for_repo(Path::new(repo)) + .expect("scoped unsynced"); + let ids: std::collections::HashSet = found.iter().map(|s| s.id).collect(); + + assert!(ids.contains(&root.id), "the repo root must match"); + assert!( + ids.contains(&nested.id), + "a nested path must match (| escaping keeps the pattern valid)" + ); + assert!( + !ids.contains(&underscore_decoy.id), + "'_' must be escaped, not treated as a single-char wildcard" + ); + assert!( + !ids.contains(&percent_decoy.id), + "'%' must be escaped, not treated as a multi-char wildcard" + ); + + // The id-scoping selector shares the same predicate, so it must agree. + let id_set = db + .get_session_ids_for_repo(Path::new(repo)) + .expect("scoped ids"); + assert!(id_set.contains(&root.id)); + assert!(id_set.contains(&nested.id)); + assert!(!id_set.contains(&underscore_decoy.id)); + assert!(!id_set.contains(&percent_decoy.id)); + } + #[test] fn test_find_active_sessions_for_directory_custom_window() { let (db, _dir) = create_test_db(); From 2a7ce3dd76a43c5c2f4d50f1e6b7b7eef9082486 Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 07:13:23 -0600 Subject: [PATCH 3/4] fix: build outgoing tree from empty base when remote lore ref is absent --- src/cli/commands/sync.rs | 153 +++++++++++++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 22 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 98e012f..50501e5 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -461,41 +461,63 @@ fn perform_sync( pulled_total += merge_remote(db, repo, &tracking_entries, key)?; merge_machines(db, repo, &tracking_entries)?; - // BUILD the outgoing tree. Base it on the remote commit when present so - // the push is a fast-forward and remote-only sessions are preserved; - // otherwise base it on the existing local ref. + // BUILD the outgoing tree. Separate the TREE BASE (which entries the new + // tree inherits) from the COMMIT PARENT (which commit it descends from): + // + // - Remote present: base the tree on the remote tracking commit so the + // push is a fast-forward and remote-only sessions are preserved. The + // commit descends from that same tracking commit. + // - No remote store: base the tree on NOTHING (empty). The local ref may + // have been written before per-repo scoping and can hold out-of-scope + // session artifacts; inheriting it wholesale would re-push another + // repo's history. Only in-scope local-only sessions are carried + // forward below. The commit still descends from the local ref (when it + // exists) so the local ref update stays a fast-forward and history is + // continuous. let tracking_commit = match fetched { Some(_) => { gitref::resolve_ref(repo, &gitref::tracking_ref_name(remote, SESSIONS_REF)?)? } None => None, }; - let base = tracking_commit.clone().or(old_local.clone()); + let tree_base = tracking_commit.clone(); + let commit_parent = tracking_commit.clone().or(old_local.clone()); let mut changes = build_session_changes(db, repo, key, &sessions)?; - // When rebasing onto the remote tracking commit, carry forward any - // already-stored session artifacts that live only in the local ref (for - // example after a remote rewind), so no stored session is silently - // dropped from the outgoing tree. - if tracking_commit.is_some() { - if let Some(local_commit) = &old_local { - let in_scope = db.get_session_ids_for_repo(repo)?; - carry_forward_local_sessions( - repo, - local_commit, - &tracking_entries, - &in_scope, - &mut changes, - )?; - } + // Carry forward already-stored, in-scope session artifacts that live + // only in the local ref so no stored in-scope session is silently + // dropped from the outgoing tree: + // + // - Remote present: a remote rewind can leave sessions in the local ref + // that are absent from the fetched remote tree. + // - No remote store: the tree base is empty, so every in-scope local-only + // session must be re-added from the local ref. + // + // Passing an empty tracking set in the no-remote case makes every local + // session a candidate; `carry_forward_local_sessions` still re-adds only + // in-scope ids, so out-of-scope artifacts are never carried forward. + if let Some(local_commit) = &old_local { + let in_scope = db.get_session_ids_for_repo(repo)?; + let carry_tracking: &[TreeEntry] = if tracking_commit.is_some() { + &tracking_entries + } else { + &[] + }; + carry_forward_local_sessions( + repo, + local_commit, + carry_tracking, + &in_scope, + &mut changes, + )?; } - add_meta_changes(db, repo, base.as_deref(), salt, machine, &mut changes)?; + add_meta_changes(db, repo, tree_base.as_deref(), salt, machine, &mut changes)?; - let tree = gitref::build_tree(repo, base.as_deref(), &changes)?; + let tree = gitref::build_tree(repo, tree_base.as_deref(), &changes)?; let message = format!("lore: sync {} session(s)", sessions.len()); - let commit = gitref::commit_tree(repo, &tree, base.as_deref(), &message)?; + let commit = gitref::commit_tree(repo, &tree, commit_parent.as_deref(), &message)?; // CAS-update the local ref, guarding against a concurrent local sync. match gitref::update_ref_checked(repo, SESSIONS_REF, &commit, old_local.as_deref()) { @@ -1960,4 +1982,91 @@ mod tests { "out-of-scope local-only metadata must not be carried forward or pushed" ); } + + #[test] + fn test_sync_no_remote_does_not_inherit_out_of_scope_local_artifacts() { + // No remote lore ref exists (the remote store was never initialized or was + // deleted), so the outgoing tree base is empty rather than the local ref. + // A local ref written before per-repo scoping can still hold out-of-scope + // session artifacts; the no-remote path must build the tree from an empty + // base and carry forward only in-scope local-only sessions, so those + // out-of-scope artifacts are neither kept in the new local ref nor pushed. + let (remote_dir, remote_url) = init_bare_remote(); + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + init_repo(repo); + git(repo, &["remote", "add", "origin", &remote_url]); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + let in_scope = seed_full_session(&mut db, "machine-a", &repo_dir(repo)); + // An out-of-scope session exists in the database (so its id is known) but + // its working directory is outside the repo, so it is out of scope. + let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project"); + + // Inject the out-of-scope session's artifacts into the LOCAL ref only, + // simulating a pre-scoping local store that still holds them. + let base = gitref::resolve_ref(repo, SESSIONS_REF).unwrap(); + let leaked_enc = gitref::write_blob(repo, b"leaked-enc").unwrap(); + let leaked_meta = gitref::write_blob(repo, b"leaked-meta").unwrap(); + let mut inject = BTreeMap::new(); + inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc); + inject.insert(format!("sessions/{out_of_scope}.meta.json"), leaked_meta); + let tree = gitref::build_tree(repo, base.as_deref(), &inject).unwrap(); + let commit = gitref::commit_tree(repo, &tree, base.as_deref(), "inject leaked").unwrap(); + gitref::update_ref_checked(repo, SESSIONS_REF, &commit, base.as_deref()).unwrap(); + + // Remove the remote lore ref so the sync takes the no-remote base path + // (fetch returns None and the tree base is empty). + git(remote_dir.path(), &["update-ref", "-d", SESSIONS_REF]); + assert!(!gitref::remote_ref_exists(repo, "origin", SESSIONS_REF).unwrap()); + + let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); + let sessions = scoped_unsynced(&db, repo); + let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); + assert_eq!(summary.pushed, 1, "only the in-scope session is pushed"); + + // The remote ref is created fresh by the push. + assert!(gitref::remote_ref_exists(repo, "origin", SESSIONS_REF).unwrap()); + + // Refresh the tracking ref so the pushed remote tree can be inspected. + gitref::fetch(repo, "origin", SESSIONS_REF).unwrap(); + + // The in-scope session is present in the new local ref and on the remote, + // while the out-of-scope local-only artifacts are gone from both. + let tracking = gitref::tracking_ref_name("origin", SESSIONS_REF).unwrap(); + for reference in [SESSIONS_REF, tracking.as_str()] { + let entries = gitref::read_tree(repo, reference).unwrap(); + assert!( + entries + .iter() + .any(|e| e.path == format!("sessions/{in_scope}.enc")), + "the in-scope session must be present in {reference}" + ); + assert!( + !entries + .iter() + .any(|e| e.path == format!("sessions/{out_of_scope}.enc")), + "out-of-scope .enc must not survive the no-remote sync in {reference}" + ); + assert!( + !entries + .iter() + .any(|e| e.path == format!("sessions/{out_of_scope}.meta.json")), + "out-of-scope .meta must not survive the no-remote sync in {reference}" + ); + // The store metadata still lands in the tree in the no-remote case. + assert!( + entries.iter().any(|e| e.path == "meta/salt"), + "meta/salt must be present in {reference}" + ); + assert!( + entries.iter().any(|e| e.path == "meta/machines.json"), + "meta/machines.json must be present in {reference}" + ); + } + } } From f9c3a5a4be511511fbf30155c61d69073890e848 Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 07:25:20 -0600 Subject: [PATCH 4/4] fix: orphan the no-remote sync commit so out-of-scope history is not pushed --- src/cli/commands/sync.rs | 50 ++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 50501e5..3bc5a5c 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -467,13 +467,16 @@ fn perform_sync( // - Remote present: base the tree on the remote tracking commit so the // push is a fast-forward and remote-only sessions are preserved. The // commit descends from that same tracking commit. - // - No remote store: base the tree on NOTHING (empty). The local ref may - // have been written before per-repo scoping and can hold out-of-scope - // session artifacts; inheriting it wholesale would re-push another - // repo's history. Only in-scope local-only sessions are carried - // forward below. The commit still descends from the local ref (when it - // exists) so the local ref update stays a fast-forward and history is - // continuous. + // - No remote store: base the tree on NOTHING (empty) and make the commit + // an ORPHAN (no parent). The local ref may have been written before + // per-repo scoping and can hold out-of-scope session artifacts; both + // inheriting its tree and descending from its commit would leak that + // history. Descending from the local ref keeps old_local reachable from + // the pushed ref (e.g. `refs/lore/sessions^`), so out-of-scope blobs + // still reach the remote through ancestry even when the tip tree is + // clean. Orphaning drops that ancestry so only the scoped tip tree and + // its blobs are reachable. Only in-scope local-only sessions are carried + // forward below, so no in-scope data is lost by orphaning. let tracking_commit = match fetched { Some(_) => { gitref::resolve_ref(repo, &gitref::tracking_ref_name(remote, SESSIONS_REF)?)? @@ -481,7 +484,7 @@ fn perform_sync( None => None, }; let tree_base = tracking_commit.clone(); - let commit_parent = tracking_commit.clone().or(old_local.clone()); + let commit_parent = tracking_commit.clone(); let mut changes = build_session_changes(db, repo, key, &sessions)?; @@ -2013,7 +2016,7 @@ mod tests { let leaked_enc = gitref::write_blob(repo, b"leaked-enc").unwrap(); let leaked_meta = gitref::write_blob(repo, b"leaked-meta").unwrap(); let mut inject = BTreeMap::new(); - inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc); + inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc.clone()); inject.insert(format!("sessions/{out_of_scope}.meta.json"), leaked_meta); let tree = gitref::build_tree(repo, base.as_deref(), &inject).unwrap(); let commit = gitref::commit_tree(repo, &tree, base.as_deref(), "inject leaked").unwrap(); @@ -2068,5 +2071,34 @@ mod tests { "meta/machines.json must be present in {reference}" ); } + + // A clean tip tree is not enough: the pushed commit must not descend from + // the pre-scoping local commit, or the out-of-scope blobs would still + // reach the remote through ancestry (e.g. `refs/lore/sessions^`). Assert + // the no-remote commit is an ORPHAN on both the local ref and the remote: + // exactly one commit is reachable, so it has no parent. + for (label, dir) in [("local", repo), ("remote", remote_dir.path())] { + assert_eq!( + git_out(dir, &["rev-list", "--count", SESSIONS_REF]), + "1", + "the no-remote sync commit must be an orphan on the {label} ref" + ); + } + + // The out-of-scope blob object must not be reachable from the pushed ref + // through any commit in its history. `git rev-list --objects` enumerates + // every object reachable from the ref, so the leaked blob oid and its path + // must both be absent. + for (label, dir) in [("local", repo), ("remote", remote_dir.path())] { + let objects = git_out(dir, &["rev-list", "--objects", SESSIONS_REF]); + assert!( + !objects.contains(&leaked_enc), + "out-of-scope blob object must not be reachable from the {label} ref" + ); + assert!( + !objects.contains(&format!("sessions/{out_of_scope}.enc")), + "out-of-scope .enc path must not be reachable from the {label} ref" + ); + } } }