remem-ai 0.6.88

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::collections::BTreeSet;
use std::env;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Component, Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::de::{self, DeserializeOwned, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Number, Value};
use sha2::{Digest, Sha256};

use super::super::process::{command_output, ensure_success};

#[derive(Debug)]
pub(super) struct FileSnapshot {
    pub(super) bytes: Vec<u8>,
    pub(super) sha256: String,
    pub(super) uid: u32,
    pub(super) mode: u32,
}

#[derive(Debug)]
pub(super) struct GitBinding {
    pub(super) root: PathBuf,
    pub(super) head: String,
    pub(super) branch: String,
    pub(super) origin_main: String,
}

pub(super) fn load_git_binding(cwd: &Path) -> Result<GitBinding> {
    let root = PathBuf::from(git_stdout(cwd, &["rev-parse", "--show-toplevel"])?);
    let root = fs::canonicalize(&root).context("canonicalize live approval repository root")?;
    let head = git_stdout(&root, &["rev-parse", "--verify", "HEAD^{commit}"])?;
    let branch = git_stdout(&root, &["branch", "--show-current"])?;
    let origin_main = git_stdout(
        &root,
        &["rev-parse", "--verify", "refs/remotes/origin/main^{commit}"],
    )?;
    Ok(GitBinding {
        root,
        head: head.to_ascii_lowercase(),
        branch,
        origin_main: origin_main.to_ascii_lowercase(),
    })
}

pub(super) fn ensure_ancestor(git: &GitBinding, ancestor: &str) -> Result<()> {
    let output = command_output(
        "git",
        ["merge-base", "--is-ancestor", ancestor, &git.head],
        &git.root,
        &[],
        30_000,
    )?;
    ensure_success("approved commit ancestry check", &output)
}

pub(super) fn read_tracked_head_json<T: DeserializeOwned>(
    git: &GitBinding,
    path: &Path,
    label: &str,
) -> Result<(T, FileSnapshot)> {
    let (absolute, relative) = confined_repo_path(&git.root, path)?;
    let relative_text = relative
        .to_str()
        .context("live approval policy path is not UTF-8")?;
    if relative_text.contains(':') {
        bail!("{label} path must not contain ':'");
    }
    git_stdout(
        &git.root,
        &["ls-files", "--error-unmatch", "--", relative_text],
    )
    .with_context(|| format!("{label} is not tracked at HEAD"))?;
    let snapshot = read_nofollow(&absolute, label)?;
    let head_blob = git_stdout_raw(&git.root, &["show", &format!("HEAD:{relative_text}")])?;
    if head_blob.as_bytes() != snapshot.bytes {
        bail!("{label} differs from its tracked HEAD blob");
    }
    let parsed = parse_unique_json(&snapshot.bytes).with_context(|| format!("parse {label}"))?;
    Ok((parsed, snapshot))
}

pub(super) fn read_json_nofollow<T: DeserializeOwned>(
    path: &Path,
    label: &str,
) -> Result<(T, FileSnapshot)> {
    let snapshot = read_nofollow(path, label)?;
    let parsed = parse_unique_json(&snapshot.bytes).with_context(|| format!("parse {label}"))?;
    Ok((parsed, snapshot))
}

pub(super) fn read_nofollow(path: &Path, label: &str) -> Result<FileSnapshot> {
    let mut options = OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
    let mut file = options.open(path).with_context(|| {
        format!(
            "open {label} without following final symlink {}",
            path.display()
        )
    })?;
    let metadata = file
        .metadata()
        .with_context(|| format!("inspect opened {label} {}", path.display()))?;
    if !metadata.is_file() {
        bail!("{label} must be a regular file");
    }
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)
        .with_context(|| format!("read opened {label} {}", path.display()))?;
    #[cfg(unix)]
    let (uid, mode) = (metadata.uid(), metadata.mode());
    #[cfg(not(unix))]
    let (uid, mode) = (u32::MAX, 0_u32);
    Ok(FileSnapshot {
        sha256: sha256_hex(&bytes),
        bytes,
        uid,
        mode,
    })
}

pub(super) fn resolve_executable(value: &str, cwd: &Path) -> Result<PathBuf> {
    let requested = Path::new(value);
    if requested.is_absolute() || requested.components().count() > 1 {
        return Ok(if requested.is_absolute() {
            requested.to_path_buf()
        } else {
            cwd.join(requested)
        });
    }
    let path = env::var_os("PATH").context("PATH is unavailable while resolving executable")?;
    env::split_paths(&path)
        .map(|directory| directory.join(requested))
        .find(|candidate| candidate.is_file())
        .with_context(|| format!("resolve executable {value:?} from PATH"))
}

pub(super) fn sha256_hex(bytes: &[u8]) -> String {
    format!("{:x}", Sha256::digest(bytes))
}

fn confined_repo_path(root: &Path, path: &Path) -> Result<(PathBuf, PathBuf)> {
    let relative = if path.is_absolute() {
        path.strip_prefix(root)
            .context("live approval policy path is outside repository root")?
            .to_path_buf()
    } else {
        path.to_path_buf()
    };
    if relative.as_os_str().is_empty()
        || relative
            .components()
            .any(|component| !matches!(component, Component::Normal(_)))
    {
        bail!("live approval policy path must be a confined repository-relative file");
    }
    let parent = relative.parent().unwrap_or_else(|| Path::new(""));
    let canonical_parent =
        fs::canonicalize(root.join(parent)).context("canonicalize live approval policy parent")?;
    if !canonical_parent.starts_with(root) {
        bail!("live approval policy parent escapes repository root");
    }
    let file_name = relative
        .file_name()
        .context("live approval policy path has no file name")?;
    Ok((canonical_parent.join(file_name), relative))
}

fn git_stdout(cwd: &Path, args: &[&str]) -> Result<String> {
    let output = command_output("git", args, cwd, &[], 30_000)?;
    ensure_success("live approval git command", &output)?;
    let value = output.stdout.trim().to_string();
    if value.is_empty() {
        bail!("live approval git command returned empty output");
    }
    Ok(value)
}

fn git_stdout_raw(cwd: &Path, args: &[&str]) -> Result<String> {
    let output = command_output("git", args, cwd, &[], 30_000)?;
    ensure_success("live approval git command", &output)?;
    Ok(output.stdout)
}

fn parse_unique_json<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
    let unique: UniqueJson = serde_json::from_slice(bytes)?;
    serde_json::from_value(unique.0).map_err(Into::into)
}

struct UniqueJson(Value);

impl<'de> Deserialize<'de> for UniqueJson {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_any(UniqueJsonVisitor)
    }
}

struct UniqueJsonVisitor;

impl<'de> Visitor<'de> for UniqueJsonVisitor {
    type Value = UniqueJson;

    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("JSON without duplicate object keys")
    }

    fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::Bool(value)))
    }

    fn visit_i64<E>(self, value: i64) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::Number(Number::from(value))))
    }

    fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::Number(Number::from(value))))
    }

    fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E>
    where
        E: de::Error,
    {
        Number::from_f64(value)
            .map(Value::Number)
            .map(UniqueJson)
            .ok_or_else(|| E::custom("non-finite JSON number"))
    }

    fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::String(value.to_string())))
    }

    fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::String(value)))
    }

    fn visit_none<E>(self) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::Null))
    }

    fn visit_unit<E>(self) -> std::result::Result<Self::Value, E> {
        Ok(UniqueJson(Value::Null))
    }

    fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error>
    where
        A: SeqAccess<'de>,
    {
        let mut values = Vec::new();
        while let Some(value) = sequence.next_element::<UniqueJson>()? {
            values.push(value.0);
        }
        Ok(UniqueJson(Value::Array(values)))
    }

    fn visit_map<A>(self, mut object: A) -> std::result::Result<Self::Value, A::Error>
    where
        A: MapAccess<'de>,
    {
        let mut keys = BTreeSet::new();
        let mut values = Map::new();
        while let Some((key, value)) = object.next_entry::<String, UniqueJson>()? {
            if !keys.insert(key.clone()) {
                return Err(de::Error::custom(format!("duplicate JSON key {key:?}")));
            }
            values.insert(key, value.0);
        }
        Ok(UniqueJson(Value::Object(values)))
    }
}

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

    #[test]
    fn unique_json_rejects_duplicate_keys_recursively() {
        let error = parse_unique_json::<Value>(br#"{"outer":{"value":1,"value":2}}"#)
            .expect_err("duplicate nested key must fail");
        assert!(error.to_string().contains("duplicate JSON key"));
    }

    #[cfg(unix)]
    #[test]
    fn nofollow_reader_rejects_final_symlink() {
        use std::os::unix::fs::symlink;

        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("test clock after epoch")
            .as_nanos();
        let target = std::env::temp_dir().join(format!(
            "remem-gh931-nofollow-{}-{nonce}-target.json",
            std::process::id()
        ));
        let link = std::env::temp_dir().join(format!(
            "remem-gh931-nofollow-{}-{nonce}-approval.json",
            std::process::id()
        ));
        fs::write(&target, b"{}").expect("write symlink target");
        symlink(&target, &link).expect("create final symlink");

        let error = read_nofollow(&link, "approval").expect_err("final symlink must fail");
        fs::remove_file(&link).expect("remove test symlink");
        fs::remove_file(&target).expect("remove symlink target");
        assert!(error
            .to_string()
            .contains("without following final symlink"));
    }
}