openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
use std::fs;
use std::io::Write;
use std::path::Path;

use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;

use crate::error::{OlError, ERR_HOOK_MALFORMED_JSONC, ERR_HOOK_WRITE_FAILED};

pub const ERR_ATOMIC_WRITE_FAILED: &str = "OL-1910";
pub const ERR_SYMLINK_CANONICALIZE_FAILED: &str = "OL-1911";

pub fn atomic_rewrite_jsonc<F>(path: &Path, mutate: F) -> Result<(), OlError>
where
    F: FnOnce(&CstRootNode) -> Result<(), OlError>,
{
    let real_path = if path.is_symlink() || path.exists() {
        fs::canonicalize(path).map_err(|e| {
            OlError::new(
                ERR_SYMLINK_CANONICALIZE_FAILED,
                format!("Cannot resolve settings path '{}': {e}", path.display()),
            )
            .with_suggestion("Check that the symlink target exists and is accessible.")
        })?
    } else {
        path.to_path_buf()
    };

    if let Some(parent) = real_path.parent() {
        fs::create_dir_all(parent).map_err(|e| {
            OlError::new(
                ERR_HOOK_WRITE_FAILED,
                format!("Cannot create settings directory: {e}"),
            )
        })?;
    }

    let raw_jsonc = if real_path.exists() {
        fs::read_to_string(&real_path).map_err(|e| {
            OlError::new(
                ERR_HOOK_WRITE_FAILED,
                format!("Cannot read settings file: {e}"),
            )
        })?
    } else {
        "{}".to_string()
    };

    let root = CstRootNode::parse(&raw_jsonc, &ParseOptions::default()).map_err(|e| {
        OlError::new(
            ERR_HOOK_MALFORMED_JSONC,
            format!("Cannot parse settings.json as JSONC: {e}"),
        )
        .with_suggestion("Fix the JSON syntax in your settings.json file.")
    })?;

    mutate(&root)?;

    let serialized = root.to_string();

    let tmp_path = real_path.with_extension("json.openlatch-tmp");
    let mut file = fs::File::create(&tmp_path).map_err(|e| {
        OlError::new(
            ERR_ATOMIC_WRITE_FAILED,
            format!("Cannot create temp file: {e}"),
        )
    })?;
    file.write_all(serialized.as_bytes()).map_err(|e| {
        OlError::new(
            ERR_ATOMIC_WRITE_FAILED,
            format!("Cannot write temp file: {e}"),
        )
    })?;
    file.sync_all().map_err(|e| {
        OlError::new(
            ERR_ATOMIC_WRITE_FAILED,
            format!("Cannot fsync temp file: {e}"),
        )
    })?;
    drop(file);

    fs::rename(&tmp_path, &real_path).map_err(|e| {
        let _ = fs::remove_file(&tmp_path);
        OlError::new(
            ERR_ATOMIC_WRITE_FAILED,
            format!("Cannot rename temp file to target: {e}"),
        )
    })?;

    #[cfg(unix)]
    {
        if let Some(parent) = real_path.parent() {
            if let Ok(dir) = fs::File::open(parent) {
                let _ = dir.sync_all();
            }
        }
    }

    Ok(())
}

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

    #[test]
    fn atomic_rewrite_creates_file_if_missing() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");

        atomic_rewrite_jsonc(&path, |root| {
            let obj = root.object_value_or_set();
            obj.append(
                "test",
                jsonc_parser::cst::CstInputValue::String("value".into()),
            );
            Ok(())
        })
        .unwrap();

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains("\"test\""));
        assert!(content.contains("\"value\""));
    }

    #[test]
    fn atomic_rewrite_preserves_existing_content() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        fs::write(&path, "{\n  \"existing\": 42\n}").unwrap();

        atomic_rewrite_jsonc(&path, |root| {
            let obj = root.object_value_or_set();
            obj.append("added", jsonc_parser::cst::CstInputValue::Bool(true));
            Ok(())
        })
        .unwrap();

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains("\"existing\": 42"));
        assert!(content.contains("\"added\""));
    }

    #[test]
    fn atomic_rewrite_is_atomic_on_error() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        let original = "{\"keep\": true}";
        fs::write(&path, original).unwrap();

        let result = atomic_rewrite_jsonc(&path, |_root| {
            Err(OlError::new("OL-TEST", "intentional failure"))
        });

        assert!(result.is_err());
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(content, original);
    }

    #[test]
    fn no_temp_file_left_on_success() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("settings.json");
        fs::write(&path, "{}").unwrap();

        atomic_rewrite_jsonc(&path, |_root| Ok(())).unwrap();

        let tmp = path.with_extension("json.openlatch-tmp");
        assert!(!tmp.exists());
    }

    #[test]
    #[cfg(unix)]
    fn atomic_rewrite_through_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let real = dir.path().join("real.json");
        let link = dir.path().join("link.json");
        fs::write(&real, "{}").unwrap();
        std::os::unix::fs::symlink(&real, &link).unwrap();

        atomic_rewrite_jsonc(&link, |root| {
            let obj = root.object_value_or_set();
            obj.append("via_symlink", jsonc_parser::cst::CstInputValue::Bool(true));
            Ok(())
        })
        .unwrap();

        let content = fs::read_to_string(&real).unwrap();
        assert!(content.contains("\"via_symlink\""));
        assert!(link.is_symlink());
    }
}