vyn-core 0.1.1

Core library for vyn. Crypto, keychain, manifest, storage, and diff engine
Documentation
use crate::ignore::IgnoreMatcher;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use walkdir::WalkDir;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
    pub path: String,
    pub sha256: String,
    pub size: u64,
    pub mode: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
    pub version: u64,
    pub files: Vec<FileEntry>,
}

#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("failed to walk directory: {0}")]
    Walk(#[from] walkdir::Error),
    #[error("failed to read file metadata: {0}")]
    Io(#[from] std::io::Error),
    #[error("invalid relative path")]
    InvalidPath,
}

impl Manifest {
    pub fn empty() -> Self {
        Self {
            version: 1,
            files: Vec::new(),
        }
    }
}

pub fn capture_manifest(root: &Path, matcher: &IgnoreMatcher) -> Result<Manifest, ManifestError> {
    let mut files = Vec::new();

    for entry in WalkDir::new(root) {
        let entry = entry?;
        let path = entry.path();
        let is_dir = entry.file_type().is_dir();

        if matcher.should_ignore(path, is_dir) {
            continue;
        }

        if is_dir {
            continue;
        }

        let rel_path = to_relative_path(root, path)?;
        let metadata = fs::metadata(path)?;
        let data = fs::read(path)?;

        files.push(FileEntry {
            path: rel_path,
            sha256: sha256_hex(&data),
            size: metadata.len(),
            mode: file_mode(&metadata),
        });
    }

    files.sort_by(|a, b| a.path.cmp(&b.path));

    Ok(Manifest { version: 1, files })
}

fn to_relative_path(root: &Path, path: &Path) -> Result<String, ManifestError> {
    let rel = path
        .strip_prefix(root)
        .map_err(|_| ManifestError::InvalidPath)?;
    Ok(path_to_unix(rel))
}

fn path_to_unix(path: &Path) -> String {
    let mut out = PathBuf::new();
    for part in path.components() {
        out.push(part.as_os_str());
    }
    out.to_string_lossy().replace('\\', "/")
}

fn sha256_hex(data: &[u8]) -> String {
    let digest = Sha256::digest(data);
    let mut output = String::with_capacity(digest.len() * 2);
    const HEX: &[u8; 16] = b"0123456789abcdef";

    for byte in digest {
        output.push(HEX[(byte >> 4) as usize] as char);
        output.push(HEX[(byte & 0x0f) as usize] as char);
    }

    output
}

#[cfg(unix)]
fn file_mode(metadata: &std::fs::Metadata) -> u32 {
    use std::os::unix::fs::PermissionsExt;
    metadata.permissions().mode()
}

#[cfg(not(unix))]
fn file_mode(_metadata: &std::fs::Metadata) -> u32 {
    0
}

#[cfg(test)]
mod tests {
    use super::capture_manifest;
    use crate::ignore::load_ignore_matcher;
    use std::fs;
    use uuid::Uuid;

    #[test]
    fn manifest_integrity() {
        let tmp = std::env::temp_dir().join(format!("vyn-manifest-{}", Uuid::new_v4()));
        fs::create_dir_all(tmp.join("dir")).expect("test directories should be created");
        fs::write(tmp.join("dir").join("a.env"), "A=1\nB=2\n")
            .expect("file a.env should be written");
        fs::write(tmp.join("b.yaml"), "name: vyn\n").expect("file b.yaml should be written");

        let matcher = load_ignore_matcher(&tmp).expect("matcher should load");
        let manifest = capture_manifest(&tmp, &matcher).expect("manifest capture should succeed");

        assert_eq!(manifest.version, 1);
        assert_eq!(manifest.files.len(), 2);
        assert_eq!(manifest.files[0].path, "b.yaml");
        assert_eq!(manifest.files[1].path, "dir/a.env");
        assert!(manifest.files.iter().all(|f| f.size > 0));

        fs::remove_dir_all(tmp).expect("temp directory should be removed");
    }
}