use super::*;
#[cfg(unix)]
pub(super) struct ValidatedDestination {
pub(super) path: PathBuf,
pub(super) file_name: std::ffi::OsString,
pub(super) directory: File,
}
#[cfg(unix)]
pub(super) struct TemporaryArchiveGuard {
pub(super) directory: File,
pub(super) name: Option<std::ffi::OsString>,
}
#[cfg(unix)]
impl TemporaryArchiveGuard {
pub(super) fn new(directory: &File, name: std::ffi::OsString) -> anyhow::Result<Self> {
Ok(Self {
directory: directory.try_clone()?,
name: Some(name),
})
}
fn disarm(&mut self) {
self.name = None;
}
fn cleanup(&mut self) -> anyhow::Result<()> {
let Some(name) = self.name.take() else {
return Ok(());
};
unlink_at(&self.directory, &name)
}
}
#[cfg(unix)]
impl Drop for TemporaryArchiveGuard {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
#[cfg(unix)]
pub(super) fn validate_destination(destination: &Path) -> anyhow::Result<ValidatedDestination> {
let file_name = destination
.file_name()
.filter(|name| *name != std::ffi::OsStr::new("."))
.ok_or_else(|| anyhow::anyhow!("session export destination must name a file"))?
.to_os_string();
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
validate_destination_parent(parent)?;
let directory = open_directory_fd(parent)?;
let path = parent.join(&file_name);
match fs::symlink_metadata(&path) {
Ok(_) => anyhow::bail!(
"session export destination already exists: {}",
path.display()
),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(ValidatedDestination {
path,
file_name,
directory,
}),
Err(error) => Err(anyhow::anyhow!(
"could not inspect session export destination {}: {error}",
path.display()
)),
}
}
#[cfg(unix)]
fn validate_destination_parent(parent: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(parent)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export destination parent must be a directory")
}
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export destination parent owner is not current user")
}
if metadata.mode() & 0o022 != 0 {
anyhow::bail!("session export destination parent is writable by group or other users")
}
}
Ok(())
}
#[cfg(unix)]
fn open_directory_fd(path: &Path) -> anyhow::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW);
Ok(options.open(path)?)
}
#[cfg(unix)]
fn unlink_at(directory: &File, name: &std::ffi::OsStr) -> anyhow::Result<()> {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::CString::new(name.as_bytes())?;
let result = unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) };
if result == 0 || io::Error::last_os_error().kind() == io::ErrorKind::NotFound {
Ok(())
} else {
Err(io::Error::last_os_error().into())
}
}
#[cfg(unix)]
fn link_at(
directory: &File,
source: &std::ffi::OsStr,
destination: &std::ffi::OsStr,
) -> io::Result<()> {
use std::os::unix::ffi::OsStrExt;
let source = std::ffi::CString::new(source.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in temporary name"))?;
let destination = std::ffi::CString::new(destination.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in destination name"))?;
let result = unsafe {
libc::linkat(
directory.as_raw_fd(),
source.as_ptr(),
directory.as_raw_fd(),
destination.as_ptr(),
0,
)
};
if result == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(unix)]
fn create_temporary_archive(directory: &File) -> anyhow::Result<(File, std::ffi::OsString)> {
for _ in 0..SESSION_EXPORT_TEMP_ATTEMPTS {
let sequence = NEXT_EXPORT_TEMP.fetch_add(1, Ordering::Relaxed);
let name = std::ffi::OsString::from(format!(
".magi-code-session-export-{}-{sequence}.tmp",
std::process::id()
));
match openat_file(directory, &name) {
Ok(file) => return Ok((file, name)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
anyhow::bail!("could not allocate a temporary session export archive")
}
#[cfg(unix)]
fn openat_file(directory: &File, name: &std::ffi::OsStr) -> io::Result<File> {
use std::os::unix::{
ffi::OsStrExt,
io::{AsRawFd, FromRawFd},
};
let c_name = std::ffi::CString::new(name.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in name"))?;
let fd = unsafe {
libc::openat(
directory.as_raw_fd(),
c_name.as_ptr(),
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0o600,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { File::from_raw_fd(fd) })
}
#[cfg(unix)]
fn serialize_manifest(manifest: &ExportManifest, omissions: &[String]) -> anyhow::Result<Vec<u8>> {
let document = ExportManifestDocument {
schema: "magi-code.session_export",
schema_version: 1,
root_session_id: manifest.root_session_id.clone(),
member_count: manifest.members.len(),
source_bytes: manifest.source_bytes,
members: manifest
.members
.iter()
.map(|member| ExportManifestMember {
path: member.archive_name.clone(),
bytes: member.length,
sha256: member.sha256.clone(),
})
.collect(),
relationships: manifest.relationships.iter().cloned().collect(),
completeness: ExportCompleteness {
complete: omissions.is_empty(),
omissions: omissions.to_vec(),
},
};
let mut bytes = serde_json::to_vec_pretty(&document)?;
bytes.push(b'\n');
Ok(bytes)
}
#[cfg(unix)]
pub(super) fn write_archive(
manifest: &mut ExportManifest,
omissions: &[String],
destination: &ValidatedDestination,
cancellation: &AgentCancellation,
warnings: &mut Vec<String>,
) -> anyhow::Result<()> {
let (temporary, temporary_name) = create_temporary_archive(&destination.directory)?;
let mut cleanup = TemporaryArchiveGuard::new(&destination.directory, temporary_name.clone())?;
let result = write_archive_inner(
manifest,
omissions,
destination,
temporary,
&temporary_name,
&mut cleanup,
cancellation,
warnings,
);
match result {
Ok(()) => Ok(()),
Err(error) => match cleanup.cleanup() {
Ok(()) => Err(error),
Err(cleanup_error) => Err(anyhow::anyhow!(
"{error}; session export cleanup diagnostic: {cleanup_error}"
)),
},
}
}
#[expect(clippy::too_many_arguments)]
#[cfg(unix)]
fn write_archive_inner(
manifest: &mut ExportManifest,
omissions: &[String],
destination: &ValidatedDestination,
temporary: File,
temporary_name: &std::ffi::OsStr,
cleanup: &mut TemporaryArchiveGuard,
cancellation: &AgentCancellation,
warnings: &mut Vec<String>,
) -> anyhow::Result<()> {
let mut archive = ZipWriter::new(temporary);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(SESSION_FILE_MODE);
cancellation.check()?;
manifest
.members
.sort_by(|left, right| left.archive_name.cmp(&right.archive_name));
let manifest_bytes = serialize_manifest(manifest, omissions)?;
archive.start_file("manifest.json", options)?;
cancellation.check()?;
archive.write_all(&manifest_bytes)?;
for member in &mut manifest.members {
cancellation.check()?;
cancellation.check()?;
archive.start_file(member.archive_name.clone(), options)?;
member.file.seek(SeekFrom::Start(0))?;
let mut source = (&mut member.file).take(member.length);
let mut hasher = Sha256::new();
let mut copied = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
cancellation.check()?;
let read = source.read(&mut buffer)?;
if read == 0 {
break;
}
archive.write_all(&buffer[..read])?;
hasher.update(&buffer[..read]);
copied = copied
.checked_add(read as u64)
.ok_or_else(|| anyhow::anyhow!("session export source-byte count overflowed"))?;
}
if copied != member.length
|| member.file.metadata()?.len() != member.length
|| lower_hex(hasher.finalize()) != member.sha256
{
anyhow::bail!(
"session export source changed while reading {}",
member.archive_name
)
}
}
cancellation.check()?;
let mut temporary = archive.finish()?;
temporary.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temporary.set_permissions(fs::Permissions::from_mode(SESSION_FILE_MODE))?;
}
temporary.sync_all()?;
drop(temporary);
cancellation.check()?;
match link_at(
&destination.directory,
temporary_name,
&destination.file_name,
) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => anyhow::bail!(
"session export destination already exists: {}",
destination.path.display()
),
Err(error) => {
return Err(anyhow::anyhow!(
"could not publish session export {}: {error}",
destination.path.display()
));
}
}
let cleanup_result = cleanup.cleanup();
cleanup.disarm();
if let Err(error) = cleanup_result {
warnings.push(bounded_warning(&format!(
"published archive temporary file cleanup failed; sensitive temporary data may remain: {error}"
)));
}
if let Err(error) = destination.directory.sync_all() {
warnings.push(bounded_warning(&format!(
"published archive directory durability uncertain: {error}"
)));
}
Ok(())
}