From 02f5c27d582d7d75d589119d9608a01c4d441119 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Wed, 8 Jul 2026 13:51:22 -0400 Subject: [PATCH] fix(gemini): derive project_path from caller/on-disk identity, not the log's directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_session_metadata preferred the chat file's internal directories()[0] over the caller-passed project path, so a session projected onto this machine from elsewhere (e.g. path resume of a Pathbase upload) reported the original author's foreign path instead of where it actually lives here. Same class of bug as toolpath-claude #103. New trust order: caller-supplied absolute path wins verbatim (checked via Path::is_absolute so Windows drive paths count too); else fall back to directories()[0], then the caller string, preserving prior behavior. A non-absolute caller string can't actually reach metadata assembly — PathResolver::project_dir never resolves a bare slot name to its own slot directory, so file resolution fails first — hence no slot reverse-lookup is needed and the public API is unchanged. --- CHANGELOG.md | 21 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-gemini/Cargo.toml | 2 +- crates/toolpath-gemini/src/io.rs | 80 ++++++++++++++++++++++++++----- site/_data/crates.json | 2 +- 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2d196..b6bdccf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to the Toolpath workspace are documented here. +## toolpath-gemini 0.6.1: derive `project_path` from caller/on-disk identity, not the log's `directories` — 2026-07-08 + +Same class of bug as #103 (`toolpath-claude` 0.11.1): `read_session_metadata` +preferred the chat file's internal `directories()[0]` over the caller-passed +project path, so a session projected onto this machine from elsewhere (e.g. +`path resume` of a Pathbase upload) reported the original author's foreign +path in `ConversationMetadata.project_path` instead of the path it actually +lives under here. + +Fix: new trust order, in `crates/toolpath-gemini/src/io.rs`. (1) If the +caller-passed `project_path` is absolute, use it verbatim — it's derived +from the on-disk project identity by every real caller (e.g. +`list_project_dirs`). (2) Only then fall back to the chat file's internal +`directories()[0]`, then to the caller string verbatim (preserves prior +behavior when nothing else resolves; a non-absolute caller string can't +actually reach metadata assembly today, since `PathResolver::project_dir` +never resolves a bare slot name to its own slot directory). Both +`project_root` call sites in `read_session_metadata` (main-file and +orphan-UUID-directory cases) now go through a shared +`resolve_display_project_path` helper. No public API change. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/Cargo.lock b/Cargo.lock index a799d3c7..25faf35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cf3d6237..29c7ee4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } +toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 76fdeedc..b5b09f3e 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/src/io.rs b/crates/toolpath-gemini/src/io.rs index 66a730b2..f9e9b535 100644 --- a/crates/toolpath-gemini/src/io.rs +++ b/crates/toolpath-gemini/src/io.rs @@ -6,6 +6,36 @@ use crate::reader::ConversationReader; use crate::types::{ChatFile, Conversation, ConversationMetadata, GeminiRole, LogEntry}; use std::path::PathBuf; +/// Resolve the display `project_path` for a session's metadata. +/// +/// Trust order (mirrors the toolpath-claude #103 precedent — a +/// projected/foreign session must not be able to claim an arbitrary +/// `project_path` via its own log content): +/// +/// 1. If `caller_project` is an absolute path, it wins verbatim — the +/// caller (typically derived from the on-disk project identity, e.g. +/// via `list_project_dirs`) is authoritative. +/// 2. Otherwise fall back to the chat file's internal `directories()[0]`, +/// then to `caller_project` verbatim (prior behavior, preserved for +/// back-compat when nothing else resolves). +/// +/// Note that a non-absolute `caller_project` cannot actually reach this +/// function today: [`PathResolver::project_dir`] only resolves real +/// project paths (an exact `projects.json` key, or the SHA-256 of the +/// passed string — never `tmp/` verbatim), so a bare slot name fails +/// file resolution with `ConversationNotFound` before metadata assembly. +/// The fallback arm exists only to preserve the old behavior for that +/// shape. +fn resolve_display_project_path(caller_project: &str, chat: &ChatFile) -> String { + if std::path::Path::new(caller_project).is_absolute() { + return caller_project.to_string(); + } + chat.directories() + .first() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| caller_project.to_string()) +} + /// First non-empty `"user"` prompt in a chat file, used as a session "title". fn first_user_text(chat: &ChatFile) -> Option { chat.messages @@ -246,11 +276,7 @@ impl ConvoIO { .iter() .filter(|c| c.kind.as_deref() == Some("subagent")) .count(); - let project_root: String = main - .directories() - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| project_path.to_string()); + let project_root: String = resolve_display_project_path(project_path, &main); let first_user_message = first_user_text(&main); return Ok(ConversationMetadata { session_uuid: session_id.to_string(), @@ -287,12 +313,7 @@ impl ConvoIO { .filter(|(_, c)| c.kind.as_deref() == Some("subagent")) .count(); - let project_root: String = main - .1 - .directories() - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| project_path.to_string()); + let project_root: String = resolve_display_project_path(project_path, &main.1); let first_user_message = first_user_text(&main.1); @@ -409,6 +430,43 @@ mod tests { assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo")); } + #[test] + fn test_read_session_metadata_trusts_caller_project_over_directories() { + // Mirrors the toolpath-claude #103 precedent + // (crates/toolpath-claude/src/reader.rs test + // `test_read_conversation_metadata`): the chat file's internal + // `directories` claims a foreign path, but the caller passed an + // absolute `project_path` — the caller's value must win. + // Hash-slot layout (no projects.json): the slot dir is the + // SHA-256 of the caller's project path, so file resolution works + // without any friendly-name mapping — keeping the fixture free of + // anything that could imply slot resolution is under test here. + let temp = TempDir::new().unwrap(); + let gemini = temp.path().join(".gemini"); + let slot = crate::paths::project_hash("/real/project"); + let session_dir = gemini.join("tmp").join(&slot).join("chats/session-uuid"); + fs::create_dir_all(&session_dir).unwrap(); + let main = r#"{ + "sessionId":"main-s", + "projectHash":"h", + "startTime":"2026-04-17T15:00:00Z", + "lastUpdated":"2026-04-17T15:10:00Z", + "directories":["/somewhere/else"], + "messages":[ + {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Hello"}]} + ] +}"#; + fs::write(session_dir.join("main.json"), main).unwrap(); + + let resolver = PathResolver::new().with_gemini_dir(&gemini); + let io = ConvoIO::with_resolver(resolver); + + let meta = io + .read_session_metadata("/real/project", "session-uuid") + .unwrap(); + assert_eq!(meta.project_path, "/real/project"); + } + #[test] fn test_read_session_metadata() { let (_t, io) = setup(); diff --git a/site/_data/crates.json b/site/_data/crates.json index 6b730be1..e7a2b5d2 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.6.0", + "version": "0.6.1", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini",