#[cfg(unix)]
mod archive;
#[cfg(unix)]
mod discovery;
use super::manager::{Session, SessionManager};
#[cfg(unix)]
use super::{
read::validate_session_id,
store::{
SESSION_FILE_MODE, open_existing_named, open_existing_primary, primary_path,
validate_session_root,
},
};
use crate::cancellation::AgentCancellation;
#[cfg(unix)]
use crate::persistence::CrossProcessFileLock;
#[cfg(unix)]
use crate::subagents::SubagentsOutput;
#[cfg(unix)]
use crate::{hex::lower_hex, output::sanitize_display_text};
#[cfg(unix)]
use archive::validate_destination;
#[cfg(unix)]
use archive::write_archive;
#[cfg(unix)]
use discovery::add_member;
#[cfg(unix)]
use discovery::add_optional_member;
#[cfg(unix)]
use discovery::checked_source_length;
#[cfg(unix)]
use discovery::collect_child;
#[cfg(unix)]
use discovery::collect_history;
#[cfg(unix)]
use discovery::discover_children;
#[cfg(unix)]
use serde::Serialize;
#[cfg(unix)]
use serde_json::Value;
#[cfg(unix)]
use sha2::{Digest, Sha256};
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions},
io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write},
sync::atomic::{AtomicU64, Ordering},
};
#[cfg(unix)]
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
#[cfg(unix)]
pub(crate) const SESSION_EXPORT_MAX_MEMBERS: usize = 10_000;
#[cfg(unix)]
pub(crate) const SESSION_EXPORT_MAX_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
#[cfg(unix)]
const SESSION_EXPORT_MAX_WARNINGS: usize = 64;
#[cfg(unix)]
const SESSION_EXPORT_MAX_WARNING_CHARS: usize = 500;
#[cfg(unix)]
const SESSION_EXPORT_MAX_DISCOVERY_LINE_BYTES: usize = 8 * 1024 * 1024;
#[cfg(unix)]
const SESSION_EXPORT_TEMP_ATTEMPTS: usize = 32;
#[cfg(unix)]
static NEXT_EXPORT_TEMP: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionExportReport {
pub(crate) destination: PathBuf,
pub(crate) member_count: usize,
pub(crate) source_bytes: u64,
pub(crate) warnings: Vec<String>,
}
#[cfg(unix)]
struct ExportManifest {
root_session_id: String,
members: Vec<SourceMember>,
relationships: BTreeSet<ExportRelationship>,
source_bytes: u64,
warnings: WarningCollector,
locks: Vec<CrossProcessFileLock>,
graph_nodes: usize,
}
#[cfg(unix)]
const SESSION_EXPORT_MAX_LOCKS: usize = 64;
#[cfg(unix)]
impl ExportManifest {
fn charge_graph_node(&mut self) -> anyhow::Result<()> {
self.graph_nodes = self.graph_nodes.saturating_add(1);
if self.graph_nodes > SESSION_EXPORT_MAX_MEMBERS {
anyhow::bail!(
"session export graph limit exceeded: maximum {SESSION_EXPORT_MAX_MEMBERS} nodes"
)
}
Ok(())
}
fn reserve_lock(&self, count: usize) -> anyhow::Result<()> {
if self.locks.len().saturating_add(count) > SESSION_EXPORT_MAX_LOCKS {
anyhow::bail!(
"session export lock limit exceeded: maximum {SESSION_EXPORT_MAX_LOCKS} locks"
)
}
Ok(())
}
fn lock_session(
&mut self,
path: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<()> {
self.reserve_lock(2)?;
validate_lock_target(path)?;
let active = super::manager::active_lease_target(path);
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
&active,
deadline,
|| cancellation.is_canceled(),
)?);
cancellation.check()?;
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
path,
deadline,
|| cancellation.is_canceled(),
)?);
validate_lock_target(path)?;
Ok(())
}
fn lock_jsonl(&mut self, path: &Path, cancellation: &AgentCancellation) -> anyhow::Result<()> {
self.reserve_lock(1)?;
validate_lock_target(path)?;
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
path,
deadline,
|| cancellation.is_canceled(),
)?);
validate_lock_target(path)?;
Ok(())
}
}
#[cfg(unix)]
fn validate_lock_target(path: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
anyhow::bail!("session export source is unsafe or not a regular file")
}
Ok(())
}
#[cfg(unix)]
struct SourceMember {
archive_name: String,
file: File,
length: u64,
sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg(unix)]
struct ExportRelationship {
#[serde(rename = "type")]
relationship_type: &'static str,
parent_session_id: String,
child_session_id: String,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportManifestDocument {
schema: &'static str,
schema_version: u8,
root_session_id: String,
member_count: usize,
source_bytes: u64,
members: Vec<ExportManifestMember>,
relationships: Vec<ExportRelationship>,
completeness: ExportCompleteness,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportManifestMember {
path: String,
bytes: u64,
sha256: String,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportCompleteness {
complete: bool,
omissions: Vec<String>,
}
#[derive(Default, Clone)]
#[cfg(unix)]
struct WarningCollector {
warnings: Vec<String>,
omitted: usize,
}
#[cfg(unix)]
impl WarningCollector {
fn push(&mut self, message: impl AsRef<str>) {
let message = bounded_warning(message.as_ref());
if self.warnings.len() < SESSION_EXPORT_MAX_WARNINGS {
self.warnings.push(message);
} else {
self.omitted = self.omitted.saturating_add(1);
}
}
fn finish(&mut self) -> Vec<String> {
if self.omitted > 0 {
let omitted = bounded_warning(&format!(
"omitted {} additional session export warnings",
self.omitted
));
if self.warnings.len() == SESSION_EXPORT_MAX_WARNINGS {
self.warnings.pop();
}
self.warnings.push(omitted);
self.omitted = 0;
}
std::mem::take(&mut self.warnings)
}
}
#[cfg(unix)]
fn bounded_warning(message: &str) -> String {
let sanitized = sanitize_display_text(message);
let mut chars = sanitized.chars();
let mut bounded = chars
.by_ref()
.take(SESSION_EXPORT_MAX_WARNING_CHARS)
.collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
bounded
}
pub(crate) fn export_session(
manager: &SessionManager,
session: &Session,
destination: &Path,
) -> anyhow::Result<SessionExportReport> {
export_session_with_cancellation(manager, session, destination, &AgentCancellation::default())
}
pub(crate) fn export_session_with_cancellation(
manager: &SessionManager,
session: &Session,
destination: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<SessionExportReport> {
#[cfg(not(unix))]
{
let _ = (manager, session, destination, cancellation);
anyhow::bail!(
"session export is Unix-only because private permissions cannot be guaranteed"
)
}
#[cfg(unix)]
{
cancellation.check()?;
let root = &manager.root;
validate_session_root(root)?;
validate_session_id(session.id.clone())?;
let expected_primary = primary_path(root, &session.id)?;
if session.path != expected_primary {
anyhow::bail!("session has an unexpected primary path")
}
let destination = validate_destination(destination)?;
let mut manifest = ExportManifest {
root_session_id: session.id.clone(),
members: Vec::new(),
relationships: BTreeSet::new(),
source_bytes: 0,
warnings: WarningCollector::default(),
locks: Vec::new(),
graph_nodes: 0,
};
session.ensure_active_lease()?;
manifest.lock_jsonl(&expected_primary, cancellation)?;
let primary = open_existing_primary(root, &session.id)?
.ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))?;
let primary_length = checked_source_length(primary.metadata()?.len())?;
let primary_children = discover_children(
&primary,
primary_length,
root,
&session.id,
&mut manifest,
cancellation,
)?;
add_member(
&mut manifest,
format!("{}.jsonl", session.id),
primary,
cancellation,
)?;
add_optional_member(
&mut manifest,
root,
&format!("{}.metadata.json", session.id),
format!("{}.metadata.json", session.id),
true,
cancellation,
)?;
let history_children = collect_history(
&mut manifest,
root,
&session.id,
".history",
true,
cancellation,
)?;
let child_root = root.join("subagents");
let mut pending = primary_children;
pending.extend(history_children);
let mut processed = BTreeSet::new();
while let Some(child_id) = pending.pop_first() {
cancellation.check()?;
manifest.charge_graph_node()?;
if !processed.insert(child_id.clone()) {
continue;
}
let Some(child_children) =
collect_child(&mut manifest, &child_root, root, &child_id, cancellation)?
else {
continue;
};
pending.extend(child_children);
}
cancellation.check()?;
let member_count = manifest.members.len();
let source_bytes = manifest.source_bytes;
let mut warnings = manifest.warnings.finish();
write_archive(
&mut manifest,
&warnings.clone(),
&destination,
cancellation,
&mut warnings,
)?;
Ok(SessionExportReport {
destination: destination.path,
member_count,
source_bytes,
warnings,
})
}
}
pub(crate) fn export_destination(root: &Path, session_id: &str) -> anyhow::Result<PathBuf> {
#[cfg(not(unix))]
{
let _ = (root, session_id);
anyhow::bail!(
"session export is Unix-only because private permissions cannot be guaranteed"
)
}
#[cfg(unix)]
{
validate_session_id(session_id.to_string())?;
let directory = prepare_export_directory(root)?;
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
for suffix in 0..SESSION_EXPORT_TEMP_ATTEMPTS {
let name = if suffix == 0 {
format!("{session_id}-{stamp}.zip")
} else {
format!("{session_id}-{stamp}-{suffix}.zip")
};
let path = directory.join(name);
if !path.exists() {
return Ok(path);
}
}
anyhow::bail!("could not allocate a unique session export destination")
}
}
#[cfg(unix)]
pub(crate) fn prepare_export_directory(root: &Path) -> anyhow::Result<PathBuf> {
ensure_export_root(root)?;
let exports = root.join("exports");
match fs::symlink_metadata(&exports) {
Ok(_) => validate_export_directory(&exports)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
use std::os::unix::fs::DirBuilderExt;
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&exports)?;
validate_export_directory(&exports)?;
}
Err(error) => return Err(error.into()),
}
Ok(exports)
}
#[cfg(unix)]
fn ensure_export_root(root: &Path) -> anyhow::Result<()> {
match fs::symlink_metadata(root) {
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(root)?;
}
Err(error) => return Err(error.into()),
}
let metadata = fs::symlink_metadata(root)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export storage root must be a non-symlink directory")
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export storage root owner is not current user")
}
if metadata.mode() & 0o022 != 0 {
anyhow::bail!("session export storage root is writable by group or other users")
}
}
Ok(())
}
#[cfg(unix)]
fn validate_export_directory(path: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export directory must be a non-symlink directory")
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export directory owner is not current user")
}
if metadata.mode() & 0o077 != 0 {
anyhow::bail!("session export directory permissions are not owner-private")
}
}
Ok(())
}