falsegreen 0.1.20

FalseGreen client — independent verification for coding agents
//! Local workspace snapshotting and upload for hosted verification.

use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use flate2::Compression;
use flate2::write::GzEncoder;
use ignore::{DirEntry, WalkBuilder};
use tar::Builder;

use crate::shim::config;

const ALWAYS_IGNORED_PARTS: &[&str] = &[
    ".falsegreen",
    ".git",
    ".hypothesis",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".uv-cache",
    ".venv",
    "__pycache__",
];
const ROOT_IGNORED_PARTS: &[&str] = &["build", "dist", "target"];

/// Upload a source snapshot of `workspace` to the tenant verifier.
pub fn sync(workspace: &Path, key: &str) -> Result<(), String> {
    let canonical = workspace
        .canonicalize()
        .map_err(|error| format!("cannot resolve workspace {}: {error}", workspace.display()))?;
    if !canonical.is_dir() {
        return Err(format!(
            "workspace is not a directory: {}",
            canonical.display()
        ));
    }

    let archive =
        TemporaryArchive::new().map_err(|error| format!("cannot create snapshot: {error}"))?;
    create_archive(&canonical, archive.path())
        .map_err(|error| format!("cannot snapshot workspace: {error}"))?;

    let output = Command::new("curl")
        .arg("-sS")
        .arg("-f")
        .arg("-X")
        .arg("PUT")
        .arg(config::workspace_url())
        .arg("-H")
        .arg("Content-Type: application/gzip")
        .arg("-H")
        .arg(format!("X-API-KEY: {key}"))
        .arg("--data-binary")
        .arg(format!("@{}", archive.path().display()))
        .arg("--max-time")
        .arg("120")
        .output()
        .map_err(|error| format!("failed to upload workspace: {error}"))?;

    if !output.status.success() {
        let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(if detail.is_empty() {
            "workspace upload failed".to_string()
        } else {
            format!("workspace upload failed: {detail}")
        });
    }
    Ok(())
}

fn create_archive(workspace: &Path, destination: &Path) -> io::Result<()> {
    let file = OpenOptions::new()
        .write(true)
        .truncate(true)
        .open(destination)?;
    let encoder = GzEncoder::new(file, Compression::default());
    let mut archive = Builder::new(encoder);
    archive.follow_symlinks(false);
    append_workspace(&mut archive, workspace)?;
    let encoder = archive.into_inner()?;
    encoder.finish()?;
    Ok(())
}

fn append_workspace<W: io::Write>(archive: &mut Builder<W>, workspace: &Path) -> io::Result<()> {
    let mut walker = WalkBuilder::new(workspace);
    walker
        .hidden(false)
        .follow_links(false)
        .git_ignore(true)
        .git_global(false)
        .git_exclude(true)
        .parents(true)
        .sort_by_file_path(|left, right| left.cmp(right))
        .filter_entry(|entry| entry.depth() == 0 || !ignored(entry));

    for entry in walker.build() {
        let entry = entry.map_err(io::Error::other)?;
        if entry.depth() == 0 {
            continue;
        }
        let path = entry.path();
        let relative = path.strip_prefix(workspace).map_err(io::Error::other)?;
        let metadata = fs::symlink_metadata(&path)?;
        let file_type = metadata.file_type();
        if file_type.is_dir() {
            archive.append_dir(relative, &path)?;
        } else if file_type.is_file() || file_type.is_symlink() {
            archive.append_path_with_name(&path, relative)?;
        } else {
            return Err(io::Error::other(format!(
                "unsupported workspace entry: {}",
                relative.display()
            )));
        }
    }
    Ok(())
}

fn ignored(entry: &DirEntry) -> bool {
    let name = entry.file_name();
    name.to_str()
        .is_some_and(|name| name.starts_with(".bench-"))
        || ALWAYS_IGNORED_PARTS
            .iter()
            .any(|ignored| name == OsStr::new(ignored))
        || (entry.depth() == 1
            && ROOT_IGNORED_PARTS
                .iter()
                .any(|ignored| name == OsStr::new(ignored)))
}

struct TemporaryArchive {
    path: PathBuf,
}

impl TemporaryArchive {
    fn new() -> io::Result<Self> {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "falsegreen-workspace-{}-{nonce}.tar.gz",
            std::process::id()
        ));
        File::create_new(&path)?;
        Ok(Self { path })
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TemporaryArchive {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use flate2::read::GzDecoder;
    use std::io::Read;

    fn test_directory() -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let directory = std::env::temp_dir().join(format!(
            "falsegreen-workspace-test-{}-{nonce}",
            std::process::id()
        ));
        fs::create_dir(&directory).unwrap();
        directory
    }

    #[test]
    fn archive_contains_source_and_excludes_generated_state() {
        let workspace = test_directory();
        fs::write(workspace.join("main.rs"), "fn main() {}\n").unwrap();
        fs::write(workspace.join("draft.txt"), "untracked source\n").unwrap();
        fs::write(workspace.join(".gitignore"), ".bench-*/\n").unwrap();
        fs::create_dir(workspace.join("target")).unwrap();
        fs::write(workspace.join("target/output"), "ignored").unwrap();
        fs::create_dir(workspace.join(".bench-generated")).unwrap();
        fs::write(workspace.join(".bench-generated/output"), "ignored").unwrap();
        fs::create_dir(workspace.join("src")).unwrap();
        fs::write(workspace.join("src/lib.rs"), "pub fn works() {}\n").unwrap();
        fs::create_dir_all(workspace.join("src/pip/_internal/operations/build")).unwrap();
        fs::write(
            workspace.join("src/pip/_internal/operations/build/__init__.py"),
            "# tracked source package\n",
        )
        .unwrap();

        let archive_path = workspace.parent().unwrap().join(format!(
            "falsegreen-workspace-test-{}.tar.gz",
            std::process::id()
        ));
        File::create(&archive_path).unwrap();
        create_archive(&workspace, &archive_path).unwrap();

        let decoder = GzDecoder::new(File::open(&archive_path).unwrap());
        let mut archive = tar::Archive::new(decoder);
        let mut paths = Vec::new();
        for entry in archive.entries().unwrap() {
            let mut entry = entry.unwrap();
            paths.push(entry.path().unwrap().to_string_lossy().to_string());
            let mut sink = Vec::new();
            entry.read_to_end(&mut sink).unwrap();
        }
        assert!(paths.contains(&"main.rs".to_string()));
        assert!(paths.contains(&"draft.txt".to_string()));
        assert!(paths.contains(&"src/lib.rs".to_string()));
        assert!(paths.contains(&"src/pip/_internal/operations/build/__init__.py".to_string()));
        assert!(!paths.iter().any(|path| path.starts_with("target")));
        assert!(
            !paths
                .iter()
                .any(|path| path.starts_with(".bench-generated"))
        );

        fs::remove_file(archive_path).unwrap();
        fs::remove_dir_all(workspace).unwrap();
    }

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

        let workspace = test_directory();
        fs::write(workspace.join("source.txt"), "source\n").unwrap();
        symlink("source.txt", workspace.join("current")).unwrap();
        let archive_path = workspace.parent().unwrap().join(format!(
            "falsegreen-workspace-symlink-test-{}.tar.gz",
            std::process::id()
        ));
        File::create(&archive_path).unwrap();
        create_archive(&workspace, &archive_path).unwrap();

        let decoder = GzDecoder::new(File::open(&archive_path).unwrap());
        let mut archive = tar::Archive::new(decoder);
        let current = archive
            .entries()
            .unwrap()
            .map(Result::unwrap)
            .find(|entry| entry.path().unwrap() == Path::new("current"))
            .unwrap();
        assert!(current.header().entry_type().is_symlink());
        assert_eq!(
            current.link_name().unwrap().unwrap(),
            Path::new("source.txt")
        );

        fs::remove_file(archive_path).unwrap();
        fs::remove_dir_all(workspace).unwrap();
    }
}