From 0a90891c041325420cb670310ae6a761c487df9b Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 22:27:28 -0400 Subject: [PATCH 1/3] fix(export): replace JSONL targets atomically Signed-off-by: samzong --- src/session.rs | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/session.rs b/src/session.rs index 93d15d7..294093a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -513,9 +513,7 @@ fn cmd_session_export( includes: crate::export::parse_export_includes(include)?, }; if let Some(path) = output { - ensure_parent_dir(&path)?; - let file = fs::File::create(&path)?; - crate::export::write_jsonl(&store, &options, file)?; + write_jsonl_file(&store, &options, &path)?; } else { let stdout = std::io::stdout(); let handle = stdout.lock(); @@ -984,6 +982,19 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { Ok(()) } +fn write_jsonl_file(store: &Store, options: &ExportOptions, path: &Path) -> Result<()> { + ensure_parent_dir(path)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + crate::export::write_jsonl(store, options, temp.as_file_mut())?; + temp.as_file().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + fn read_tldr_file(path: &Path) -> Option { match fs::read_to_string(path) { Ok(content) => { @@ -1037,4 +1048,30 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn jsonl_file_export_replaces_target_only_after_success() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("export.jsonl"); + fs::write(&path, "keep").unwrap(); + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let mut options = ExportOptions { + session_ids: vec!["missing".to_string()], + sources: None, + time_range: TimeRange::All, + scope: ProjectScope::Global, + thread_role: None, + limit: None, + includes: ExportIncludes::full(), + }; + + assert!(write_jsonl_file(&store, &options, &path).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "keep"); + + options.session_ids.clear(); + write_jsonl_file(&store, &options, &path).unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), ""); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + } } From 391f7dff0f5289cd4fe37db5b193d7b5c9d7df71 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 22:36:15 -0400 Subject: [PATCH 2/3] fix(export): preserve output permissions Signed-off-by: samzong --- src/session.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/session.rs b/src/session.rs index 294093a..ade621f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -988,8 +988,28 @@ fn write_jsonl_file(store: &Store, options: &ExportOptions, path: &Path) -> Resu .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - let mut temp = tempfile::NamedTempFile::new_in(parent)?; + #[cfg(unix)] + let mut builder = tempfile::Builder::new(); + #[cfg(not(unix))] + let builder = tempfile::Builder::new(); + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + let existing_permissions = match fs::metadata(path) { + Ok(metadata) => Some(fs::Permissions::from_mode(metadata.permissions().mode() & 0o777)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + #[cfg(unix)] + if existing_permissions.is_none() { + builder.permissions(fs::Permissions::from_mode(0o666)); + } + let mut temp = builder.tempfile_in(parent)?; crate::export::write_jsonl(store, options, temp.as_file_mut())?; + #[cfg(unix)] + if let Some(permissions) = existing_permissions { + temp.as_file().set_permissions(permissions)?; + } temp.as_file().sync_all()?; temp.persist(path).map_err(|error| error.error)?; Ok(()) @@ -1054,6 +1074,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("export.jsonl"); fs::write(&path, "keep").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + } crate::db::schema::register_sqlite_vec(); let store = Store::open_in_memory().unwrap(); let mut options = ExportOptions { @@ -1073,5 +1098,13 @@ mod tests { write_jsonl_file(&store, &options, &path).unwrap(); assert_eq!(fs::read_to_string(path).unwrap(), ""); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(dir.path().join("export.jsonl")).unwrap().permissions().mode() & 0o777, + 0o640 + ); + } } } From ea6f901d84eef593d5fd82239c654f4e8e85d522 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 22:42:17 -0400 Subject: [PATCH 3/3] fix(export): preserve output symlinks Signed-off-by: samzong --- src/session.rs | 54 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/session.rs b/src/session.rs index ade621f..1f7e2eb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -984,6 +984,8 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { fn write_jsonl_file(store: &Store, options: &ExportOptions, path: &Path) -> Result<()> { ensure_parent_dir(path)?; + let target = resolve_output_target(path)?; + let path = target.as_path(); let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) @@ -1015,6 +1017,30 @@ fn write_jsonl_file(store: &Store, options: &ExportOptions, path: &Path) -> Resu Ok(()) } +fn resolve_output_target(path: &Path) -> Result { + let mut target = path.to_path_buf(); + for _ in 0..40 { + match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let link = fs::read_link(&target)?; + target = if link.is_absolute() { + link + } else { + target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join(link) + }; + } + Ok(_) => return Ok(target), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(target), + Err(error) => return Err(error.into()), + } + } + anyhow::bail!("too many symbolic links in output path: {}", path.display()) +} + fn read_tldr_file(path: &Path) -> Option { match fs::read_to_string(path) { Ok(content) => { @@ -1072,13 +1098,21 @@ mod tests { #[test] fn jsonl_file_export_replaces_target_only_after_success() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("export.jsonl"); - fs::write(&path, "keep").unwrap(); + let target_path = dir.path().join("export.jsonl"); + fs::write(&target_path, "keep").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + fs::set_permissions(&target_path, fs::Permissions::from_mode(0o640)).unwrap(); } + #[cfg(unix)] + let path = { + let path = dir.path().join("export-link.jsonl"); + std::os::unix::fs::symlink("export.jsonl", &path).unwrap(); + path + }; + #[cfg(not(unix))] + let path = target_path.clone(); crate::db::schema::register_sqlite_vec(); let store = Store::open_in_memory().unwrap(); let mut options = ExportOptions { @@ -1092,19 +1126,19 @@ mod tests { }; assert!(write_jsonl_file(&store, &options, &path).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), "keep"); + assert_eq!(fs::read_to_string(&target_path).unwrap(), "keep"); options.session_ids.clear(); write_jsonl_file(&store, &options, &path).unwrap(); - assert_eq!(fs::read_to_string(path).unwrap(), ""); - assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(fs::read_to_string(&target_path).unwrap(), ""); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(dir.path().join("export.jsonl")).unwrap().permissions().mode() & 0o777, - 0o640 - ); + assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2); + assert_eq!(fs::metadata(target_path).unwrap().permissions().mode() & 0o777, 0o640); } + #[cfg(not(unix))] + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); } }