Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ agentflare-backend = { package = "agentflare-backend", path = "crates/agentflare
db_kit = { package = "agentflare-db-kit", path = "crates/agentflare-db-kit" }
agent-detector = "0.2.1"

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[features]
default = []
process-tree = ["sysinfo"]
Expand Down
6 changes: 4 additions & 2 deletions src/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ fn write_managed_block(
end += 1;
}
let new_content = content[..start].to_string() + &new_block + &content[end..];
std::fs::write(profile, new_content)?;
crate::atomic_fs::write_bytes_with_fallback(profile, new_content.as_bytes(), None)
.map_err(std::io::Error::other)?;
return Ok(());
}

Expand All @@ -301,7 +302,8 @@ fn write_managed_block(
new_content.push('\n');
}
new_content.push_str(&new_block);
std::fs::write(profile, new_content)?;
crate::atomic_fs::write_bytes_with_fallback(profile, new_content.as_bytes(), None)
.map_err(std::io::Error::other)?;
Ok(())
}

Expand Down
303 changes: 303 additions & 0 deletions src/atomic_fs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
//! Shared, policy-free atomic-write mechanics.
//!
//! agentflare writes files whose truncation on a crash mid-write would be a
//! real problem — auth vault credential files (`auth.rs`) and the user's own
//! shell profile (`alias.rs`'s managed alias block) — using plain
//! `fs::write`, which is not crash-atomic: a process killed between the
//! `open` and the `write` completing leaves a truncated (or zero-byte) file
//! in place of the original.
//!
//! This module provides one audited mechanism: a same-directory temp file +
//! `rename`, with an in-place-overwrite fallback when the directory is
//! read-only but the file inode itself is writable. Ported from lean-ctx's
//! `core/atomic_fs.rs`.

use std::borrow::Cow;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

fn invalid_input(msg: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
}

/// Resolves one level of symlink indirection so an atomic write lands on the
/// real file `path` points at, not on a new plain file replacing the symlink
/// itself — `rename()` does not follow a symlink destination, it unlinks it
/// and puts the new file in its place. A relative link target is resolved
/// against the symlink's own parent directory. Non-symlinks (and symlinks
/// this can't stat, e.g. a dangling one) pass through unchanged.
fn resolve_symlink_target(path: &Path) -> Cow<'_, Path> {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return Cow::Borrowed(path);
};
if !meta.file_type().is_symlink() {
return Cow::Borrowed(path);
}
let Ok(target) = std::fs::read_link(path) else {
return Cow::Borrowed(path);
};
if target.is_absolute() {
Cow::Owned(target)
} else {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
Cow::Owned(parent.join(target))
}
}
Comment on lines +15 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect in_place_overwrite / write_bytes_with_fallback for symlink handling.
sed -n '117,192p' src/atomic_fs.rs

Repository: getappz/agentflare

Length of output: 2662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '47,116p' src/atomic_fs.rs

Repository: getappz/agentflare

Length of output: 2651


Resolve the symlink before the fallback write

write_bytes_with_fallback still passes the original path into in_place_overwrite, and O_NOFOLLOW will reject symlinks on Unix. Because path.is_file() follows symlinks, writable targets behind a symlink can still reach this branch and fail; resolve the link here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/atomic_fs.rs` around lines 15 - 45, Update write_bytes_with_fallback to
resolve the input path through resolve_symlink_target before calling
in_place_overwrite. Pass the resolved target to the fallback write while
preserving the existing behavior for non-symlinks and other write paths.


/// Durable, crash-atomic write: a temp file in the **same directory** as `path`
/// (after resolving one level of symlink, so a symlinked target is written
/// through rather than replaced) followed by `rename` over the target.
/// Requires write permission on the parent directory; the read-only-directory
/// fallback is handled by [`write_bytes_with_fallback`].
///
/// When `permissions` is `None` and the target already exists, its current
/// permissions are carried over to the replacement — otherwise the new inode
/// would get the process's default (umask-masked) mode, silently loosening
/// e.g. a `0600` credential file to world-readable.
pub fn try_atomic_write(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> std::io::Result<()> {
use std::io::Write;

let resolved = resolve_symlink_target(path);
let path: &Path = &resolved;

let parent = path
.parent()
.ok_or_else(|| invalid_input("invalid path (no parent directory)"))?;
let filename = path
.file_name()
.ok_or_else(|| invalid_input("invalid path (no filename)"))?
.to_string_lossy();

let owned_perms;
let permissions = match permissions {
Some(p) => Some(p),
None => {
owned_perms = std::fs::metadata(path).ok().map(|m| m.permissions());
owned_perms.as_ref()
}
};

let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = parent.join(format!(".{filename}.agentflare.tmp.{pid}.{nanos}"));

{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
f.write_all(bytes)?;
let _ = f.flush();
let _ = f.sync_all();
}

if let Some(perms) = permissions {
let _ = std::fs::set_permissions(&tmp, perms.clone());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// std::fs::rename already replaces an existing destination on every
// platform we build for (including Windows, via MoveFileExW's
// MOVEFILE_REPLACE_EXISTING) — no separate pre-removal needed, and one
// would only open a window where neither the old nor new file exists.
if let Err(e) = std::fs::rename(&tmp, path) {
// Don't leave a half-written temp behind before the caller decides
// whether to fall back.
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}

/// In-place overwrite of an existing file inode (`O_WRONLY|O_TRUNC`, plus
/// `O_NOFOLLOW` on Unix). Works when the parent directory is read-only but the
/// file itself is writable. Not crash-atomic — used only as a fallback when the
/// atomic path is impossible.
pub fn in_place_overwrite(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> std::io::Result<()> {
use std::io::Write;

let mut opts = std::fs::OpenOptions::new();
opts.write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// O_NOFOLLOW: a symlink swapped in after the caller's checks must never
// be followed here.
opts.custom_flags(libc::O_NOFOLLOW);
}

let mut f = opts.open(path)?;
f.write_all(bytes)?;
let _ = f.flush();
let _ = f.sync_all();

if let Some(perms) = permissions {
let _ = std::fs::set_permissions(path, perms.clone());
}
Ok(())
}

/// True for errors that mean "this directory won't accept create/rename" even
/// though the target file may be writable: `EROFS` (read-only fs) plus
/// `EACCES`/`EPERM` (directory write denied).
pub fn is_readonly_dir_error(e: &std::io::Error) -> bool {
if e.kind() == std::io::ErrorKind::PermissionDenied {
return true;
}
#[cfg(unix)]
{
matches!(
e.raw_os_error(),
Some(libc::EROFS | libc::EACCES | libc::EPERM)
)
}
#[cfg(not(unix))]
{
false
}
}

/// Atomic write with the read-only-directory in-place fallback. Tries the
/// crash-atomic temp+rename first; if that fails because the *directory* is
/// read-only/permission-denied but an existing file inode is writable, overwrite
/// it in place. `permissions`, when given, is applied to the written file.
pub fn write_bytes_with_fallback(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> Result<(), String> {
match try_atomic_write(path, bytes, permissions) {
Ok(()) => Ok(()),
Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
format!(
"atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
path.display()
)
})
}
Err(e) => Err(format!("atomic write failed: {e} ({})", path.display())),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn readonly_dir_error_classification() {
assert!(is_readonly_dir_error(&std::io::Error::from(
std::io::ErrorKind::PermissionDenied
)));
assert!(!is_readonly_dir_error(&std::io::Error::from(
std::io::ErrorKind::NotFound
)));
#[cfg(unix)]
{
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EROFS
)));
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EACCES
)));
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EPERM
)));
}
}

#[test]
fn try_atomic_write_creates_and_replaces() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg.toml");
try_atomic_write(&path, b"first", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
// No leftover temp files.
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".agentflare.tmp."))
.collect();
assert!(strays.is_empty(), "temp file must not linger");
try_atomic_write(&path, b"second", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
}

#[cfg(unix)]
#[test]
fn try_atomic_write_preserves_existing_permissions_when_none_given() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secret.json");
std::fs::write(&path, b"original").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();

try_atomic_write(&path, b"updated", None).unwrap();

let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"replacement must keep the original file's mode"
);
}

#[cfg(unix)]
#[test]
fn try_atomic_write_writes_through_a_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.txt");
let link = dir.path().join("link.txt");
std::fs::write(&real, b"original").unwrap();
symlink(&real, &link).unwrap();

try_atomic_write(&link, b"updated", None).unwrap();

assert!(link.is_symlink(), "the symlink itself must survive");
assert_eq!(std::fs::read_link(&link).unwrap(), real);
assert_eq!(std::fs::read(&real).unwrap(), b"updated");
}

#[cfg(unix)]
#[test]
fn in_place_overwrite_truncates_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.jsonc");
std::fs::write(&path, b"longer original content").unwrap();
in_place_overwrite(&path, b"short", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"short");
}

#[cfg(unix)]
#[test]
fn fallback_overwrites_when_parent_dir_is_readonly() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg.toml");
std::fs::write(&path, b"original").unwrap();
// Read-only parent dir: temp+rename is impossible, but the file inode
// stays writable, so the in-place fallback must succeed.
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let res = write_bytes_with_fallback(&path, b"updated", None);
let _ = std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700));
res.expect("read-only-dir fallback must succeed");
assert_eq!(std::fs::read(&path).unwrap(), b"updated");
}

#[test]
fn write_bytes_with_fallback_creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("new.txt");
write_bytes_with_fallback(&path, b"hello", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"hello");
}
}
11 changes: 6 additions & 5 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ pub fn activate_with(agent: &str, profile: &str, reload_daemon: bool, json: bool
if auth_crypt::is_encrypted(&data) {
if let Some(ref pw) = passphrase {
if let Some(decrypted) = auth_crypt::decrypt(&data, pw) {
fs::write(&dest, decrypted).expect("write");
crate::atomic_fs::write_bytes_with_fallback(&dest, &decrypted, None)
.expect("write");
} else {
eprintln!(
"warning: cannot decrypt {} — wrong passphrase",
Expand All @@ -203,7 +204,7 @@ pub fn activate_with(agent: &str, profile: &str, reload_daemon: bool, json: bool
continue;
}
} else {
fs::write(&dest, data).expect("write");
crate::atomic_fs::write_bytes_with_fallback(&dest, &data, None).expect("write");
}
restored += 1;
}
Expand Down Expand Up @@ -1037,7 +1038,6 @@ fn read_isolate_mode(dir: &std::path::Path) -> Option<String> {
}
None
}

fn activate_into(agent: &str, profile: &str, target_dir: &std::path::Path) {
let cat = match catalog_for(agent) {
Some(c) => c,
Expand All @@ -1058,7 +1058,8 @@ fn activate_into(agent: &str, profile: &str, target_dir: &std::path::Path) {
if auth_crypt::is_encrypted(&data) {
if let Some(ref pw) = passphrase {
if let Some(decrypted) = auth_crypt::decrypt(&data, pw) {
fs::write(&dest, decrypted).expect("write");
crate::atomic_fs::write_bytes_with_fallback(&dest, &decrypted, None)
.expect("write");
} else {
eprintln!(
"warning: cannot decrypt {} — wrong passphrase",
Expand All @@ -1074,7 +1075,7 @@ fn activate_into(agent: &str, profile: &str, target_dir: &std::path::Path) {
continue;
}
} else {
fs::write(&dest, data).expect("write");
crate::atomic_fs::write_bytes_with_fallback(&dest, &data, None).expect("write");
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod agent_launch;
mod agents;
mod alias;
mod artifacts;
mod atomic_fs;
mod auth;
mod auth_crypt;
mod auth_db;
Expand Down
Loading