git-slop 0.16.0

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use super::ResolvedPack;
use super::validate::load_and_validate_pack;

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CacheIndex {
    #[serde(default = "index_schema")]
    schema_version: u64,
    #[serde(default)]
    packs: BTreeMap<String, String>,
}

fn index_schema() -> u64 {
    1
}

fn valid_content_digest(value: &str) -> bool {
    value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}

pub(crate) fn policy_home() -> Result<PathBuf> {
    if let Some(path) = env::var_os("GIT_SLOP_POLICY_HOME") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = env::var_os("XDG_DATA_HOME") {
        return Ok(PathBuf::from(path).join("git-slop/policies"));
    }
    if let Some(path) = env::var_os("LOCALAPPDATA") {
        return Ok(PathBuf::from(path).join("git-slop/policies"));
    }
    if let Some(path) = env::var_os("HOME") {
        return Ok(PathBuf::from(path).join(".local/share/git-slop/policies"));
    }
    bail!("unable to resolve policy cache; set GIT_SLOP_POLICY_HOME")
}

fn index_path(home: &Path) -> PathBuf {
    home.join("index.json")
}

fn read_index(home: &Path) -> Result<CacheIndex> {
    let path = index_path(home);
    if !path.exists() {
        return Ok(CacheIndex {
            schema_version: 1,
            packs: BTreeMap::new(),
        });
    }
    let index: CacheIndex = serde_json::from_slice(&fs::read(&path)?)
        .with_context(|| format!("unable to parse policy cache index {}", path.display()))?;
    if index.schema_version != 1 {
        bail!(
            "unsupported policy cache index schema {}",
            index.schema_version
        );
    }
    for (id, digest) in &index.packs {
        if !valid_content_digest(digest) {
            bail!("policy cache index has an invalid content digest for {id}");
        }
    }
    Ok(index)
}

fn write_index(home: &Path, index: &CacheIndex) -> Result<()> {
    fs::create_dir_all(home)?;
    let path = index_path(home);
    crate::config::write_text_atomically(&path, serde_json::to_string_pretty(index)? + "\n", false)
        .map(|_| ())
        .with_context(|| format!("unable to publish policy cache index {}", path.display()))
}

fn copy_file(source: &Path, destination: &Path) -> Result<()> {
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::copy(source, destination).with_context(|| {
        format!(
            "unable to copy policy-pack file {} to {}",
            source.display(),
            destination.display()
        )
    })?;
    Ok(())
}

pub(super) fn install(pack: &ResolvedPack) -> Result<PathBuf> {
    let home = policy_home()?;
    fs::create_dir_all(&home)?;
    let destination = home.join(&pack.content_digest);
    if destination.exists() {
        if fs::symlink_metadata(&destination)?.file_type().is_symlink() {
            bail!(
                "content-addressed policy cache entry must not be a symlink: {}",
                destination.display()
            );
        }
        let existing = load_and_validate_pack(&destination)?;
        if existing.content_digest != pack.content_digest
            || existing.manifest.id != pack.manifest.id
        {
            bail!(
                "content-addressed policy cache entry is inconsistent: {}",
                destination.display()
            );
        }
    } else {
        let temporary = home.join(format!(
            ".{}-{}-tmp",
            pack.content_digest,
            std::process::id()
        ));
        if temporary.exists() {
            fs::remove_dir_all(&temporary)?;
        }
        fs::create_dir(&temporary)?;
        let declared = std::iter::once("git-slop-policy.yaml".to_string())
            .chain(pack.manifest.entrypoints.iter().cloned())
            .chain(pack.manifest.tests.iter().cloned())
            .chain([
                "README.md".to_string(),
                "LICENSE".to_string(),
                "LICENCE".to_string(),
            ]);
        for relative in declared {
            let source = pack.root.join(&relative);
            if source.is_file() {
                copy_file(&source, &temporary.join(&relative))?;
            }
        }
        fs::rename(&temporary, &destination)?;
    }
    let mut index = read_index(&home)?;
    index
        .packs
        .insert(pack.manifest.id.clone(), pack.content_digest.clone());
    write_index(&home, &index)?;
    Ok(destination)
}

pub(super) fn installed(id: &str) -> Result<ResolvedPack> {
    let home = policy_home()?;
    let index = read_index(&home)?;
    let digest = index
        .packs
        .get(id)
        .ok_or_else(|| anyhow::anyhow!("policy pack is not installed: {id}"))?;
    let mut pack = load_and_validate_pack(&home.join(digest))?;
    if pack.manifest.id != id || pack.content_digest != *digest {
        bail!("installed policy pack {id} no longer matches its cache index digest");
    }
    pack.source_type = "user-cache".to_string();
    pack.source_revision = digest.clone();
    Ok(pack)
}

pub(super) fn all_installed() -> Result<Vec<ResolvedPack>> {
    let home = policy_home()?;
    let index = read_index(&home)?;
    index
        .packs
        .keys()
        .map(|id| installed(id))
        .collect::<Result<Vec<_>>>()
}

pub(super) fn remove(id: &str) -> Result<bool> {
    let home = policy_home()?;
    let mut index = read_index(&home)?;
    let Some(digest) = index.packs.remove(id) else {
        return Ok(false);
    };
    let still_referenced = index.packs.values().any(|candidate| candidate == &digest);
    write_index(&home, &index)?;
    if !still_referenced {
        let path = home.join(&digest);
        if path.exists() && fs::symlink_metadata(&path)?.file_type().is_symlink() {
            bail!(
                "refusing to remove a symlinked policy cache entry: {}",
                path.display()
            );
        }
        if path.is_dir() {
            fs::remove_dir_all(path)?;
        }
    }
    Ok(true)
}

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

    #[test]
    fn cache_index_rejects_noncanonical_or_traversing_digests() {
        let home = tempfile::tempdir().expect("policy home");
        for digest in ["../outside".to_string(), "A".repeat(64), "g".repeat(64)] {
            fs::write(
                home.path().join("index.json"),
                serde_json::to_vec(&serde_json::json!({
                    "schema_version": 1,
                    "packs": {"com.example.pack": digest.clone()}
                }))
                .unwrap(),
            )
            .unwrap();
            assert!(read_index(home.path()).is_err(), "accepted {digest}");
        }
        fs::write(
            home.path().join("index.json"),
            serde_json::to_vec(&serde_json::json!({
                "schema_version": 1,
                "packs": {"com.example.pack": "a".repeat(64)}
            }))
            .unwrap(),
        )
        .unwrap();
        assert!(read_index(home.path()).is_ok());
    }
}