falsegreen 0.1.2

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 tar::Builder;

use crate::shim::config;

const IGNORED_PARTS: &[&str] = &[
    ".falsegreen",
    ".git",
    ".hypothesis",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".uv-cache",
    ".venv",
    "__pycache__",
    "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_directory(&mut archive, workspace, workspace)?;
    let encoder = archive.into_inner()?;
    encoder.finish()?;
    Ok(())
}

fn append_directory<W: io::Write>(
    archive: &mut Builder<W>,
    workspace: &Path,
    directory: &Path,
) -> io::Result<()> {
    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        let name = entry.file_name();
        if ignored(&name) {
            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)?;
            append_directory(archive, workspace, &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(name: &OsStr) -> bool {
    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::create_dir(workspace.join("target")).unwrap();
        fs::write(workspace.join("target/output"), "ignored").unwrap();
        fs::create_dir(workspace.join("src")).unwrap();
        fs::write(workspace.join("src/lib.rs"), "pub fn works() {}\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(&"src/lib.rs".to_string()));
        assert!(!paths.iter().any(|path| path.starts_with("target")));

        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();
    }
}