ryra-core 0.3.3

Core library for ryra: config, registry, and service generation logic
Documentation
//! Render manifest for an installed service. One per install, persisted to
//! `~/.local/share/services/<svc>/service.manifest`.
//!
//! Format is `sha256sum -c` compatible so the GNU tool verifies it directly.
//! The `# ryra-manifest-version` comment lets us evolve the on-disk shape
//! later without breaking `sha256sum -c`, which ignores `#`-prefixed lines.
//!
//! `.env` is intentionally absent: it carries generated secrets that the
//! service may legitimately rotate at runtime, so flagging it as drift would
//! produce false positives.

use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

use crate::error::{Error, Result};
use crate::paths::service_home;

/// Current on-disk format version. Bump when changing the line schema.
pub const MANIFEST_VERSION: u32 = 1;

/// Filename used for the per-service manifest.
pub const MANIFEST_FILENAME: &str = "service.manifest";

/// One entry in the manifest: the absolute path of a rendered file and the
/// sha256 of its content at render time.
#[derive(Debug, Clone)]
pub struct ManifestEntry {
    pub path: PathBuf,
    pub sha256: String,
}

/// One static env var the registry expects in `.env`. "Static" means the
/// template carries no `{{secret.*}}` or `{{auth.*}}` reference — those
/// rotate at runtime and would produce false drift positives. Only static
/// vars are tracked, and tracking is append-only: `ryra upgrade` adds
/// missing keys to the user's `.env` but never removes or rewrites
/// existing lines, since users may have manually edited values or added
/// their own keys.
#[derive(Debug, Clone)]
pub struct EnvEntry {
    pub key: String,
    pub value: String,
}

/// Compute the hex sha256 of arbitrary bytes.
pub fn hash_bytes(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

/// Compute the hex sha256 of a file's contents on disk.
pub fn hash_file(path: &Path) -> Result<String> {
    let bytes = std::fs::read(path).map_err(|source| Error::FileRead {
        path: path.to_path_buf(),
        source,
    })?;
    Ok(hash_bytes(&bytes))
}

/// Resolve the manifest path for a service.
pub fn manifest_path(service_name: &str) -> Result<PathBuf> {
    Ok(service_home(service_name)?.join(MANIFEST_FILENAME))
}

/// Render the manifest content from a list of entries, sorted by path so
/// the output is stable across runs. Static env vars are recorded as
/// `# env: KEY=VALUE` comment lines — `sha256sum -c` ignores `#`-prefixed
/// lines, so the file remains verifiable with the standard tool while
/// still carrying the env metadata our own parser needs.
pub fn format(entries: &[ManifestEntry], envs: &[EnvEntry]) -> String {
    let mut sorted: Vec<&ManifestEntry> = entries.iter().collect();
    sorted.sort_by(|a, b| a.path.cmp(&b.path));
    let mut sorted_envs: Vec<&EnvEntry> = envs.iter().collect();
    sorted_envs.sort_by(|a, b| a.key.cmp(&b.key));

    let mut out = String::new();
    out.push_str("# ryra service.manifest — render manifest\n");
    out.push_str(&format!("# ryra-manifest-version: {MANIFEST_VERSION}\n"));
    out.push_str("# Generated by ryra. Verify with `sha256sum -c`.\n");
    out.push('\n');
    for entry in sorted {
        // GNU sha256sum text-mode format: `<hex>  <path>` (two spaces).
        out.push_str(&entry.sha256);
        out.push_str("  ");
        out.push_str(&entry.path.to_string_lossy());
        out.push('\n');
    }
    if !sorted_envs.is_empty() {
        out.push('\n');
        out.push_str("# env: static env vars the registry expects in .env\n");
        for env in sorted_envs {
            out.push_str(&format!("# env: {}={}\n", env.key, env.value));
        }
    }
    out
}

/// Parse a manifest, returning both the file entries and any tracked env
/// vars. Recognises `# env: KEY=VALUE` comment lines and pulls them out;
/// other comment lines and blank lines are ignored. Lines that don't
/// match `<hex>  <path>` (or the env-comment form) are an error — the
/// file is machine-written, so a malformed entry means corruption or a
/// version mismatch we don't know how to handle.
pub fn parse(content: &str) -> Result<(Vec<ManifestEntry>, Vec<EnvEntry>)> {
    let mut entries = Vec::new();
    let mut envs = Vec::new();
    for (lineno, raw) in content.lines().enumerate() {
        let line = raw.trim_start();
        if line.is_empty() {
            continue;
        }
        if let Some(rest) = line.strip_prefix("# env: ") {
            // `# env: <header text>` (no `=` in the rest) is treated as a
            // section header and skipped, so we can decorate the manifest.
            // Real entries are `# env: KEY=VALUE`.
            if let Some((key, value)) = rest.split_once('=') {
                envs.push(EnvEntry {
                    key: key.trim().to_string(),
                    value: value.to_string(),
                });
            }
            continue;
        }
        if line.starts_with('#') {
            continue;
        }
        // sha256sum text-mode separator is exactly two spaces.
        let (hash, path) = line.split_once("  ").ok_or_else(|| {
            Error::Template(format!(
                "service.manifest line {} is malformed: {raw}",
                lineno + 1
            ))
        })?;
        if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
            return Err(Error::Template(format!(
                "service.manifest line {} has non-sha256 hash: {hash}",
                lineno + 1
            )));
        }
        entries.push(ManifestEntry {
            path: PathBuf::from(path),
            sha256: hash.to_string(),
        });
    }
    Ok((entries, envs))
}

/// Load and parse the manifest for a service. `Ok(None)` when the file is
/// absent (service installed before manifests existed); `Err` when present
/// but unreadable / malformed. Returns both the file entries and any
/// tracked static env vars.
pub fn load(service_name: &str) -> Result<Option<(Vec<ManifestEntry>, Vec<EnvEntry>)>> {
    let path = manifest_path(service_name)?;
    if !path.exists() {
        return Ok(None);
    }
    let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
        path: path.clone(),
        source,
    })?;
    Ok(Some(parse(&content)?))
}

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

    #[test]
    fn format_round_trips_through_parse() {
        let entries = vec![
            ManifestEntry {
                path: PathBuf::from("/a/b/c.container"),
                sha256: "a".repeat(64),
            },
            ManifestEntry {
                path: PathBuf::from("/a/b/scripts/x.sh"),
                sha256: "b".repeat(64),
            },
        ];
        let rendered = format(&entries, &[]);
        let (parsed, envs) = parse(&rendered).expect("round-trip parse must succeed");
        assert_eq!(parsed.len(), 2);
        assert!(envs.is_empty());
        // Sorted by path; "/a/b/c.container" < "/a/b/scripts/x.sh".
        assert_eq!(parsed[0].path, PathBuf::from("/a/b/c.container"));
        assert_eq!(parsed[0].sha256, "a".repeat(64));
        assert_eq!(parsed[1].path, PathBuf::from("/a/b/scripts/x.sh"));
        assert_eq!(parsed[1].sha256, "b".repeat(64));
    }

    #[test]
    fn parse_ignores_comments_and_blank_lines() {
        let input = "# header\n\n# another comment\n\
                     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  /x\n";
        let (parsed, envs) = parse(input).expect("parse must succeed");
        assert_eq!(parsed.len(), 1);
        assert!(envs.is_empty());
        assert_eq!(parsed[0].path, PathBuf::from("/x"));
    }

    #[test]
    fn parse_rejects_non_hex_hash() {
        let input = "zzzz  /x\n";
        let err = parse(input).expect_err("non-hex hash must be rejected");
        let msg = err.to_string();
        assert!(
            msg.contains("non-sha256") || msg.contains("malformed"),
            "{msg}"
        );
    }

    #[test]
    fn parse_rejects_short_hash() {
        // Has the two-space separator but the hash isn't 64 chars.
        let input = "abc  /x\n";
        let err = parse(input).expect_err("short hash must be rejected");
        assert!(err.to_string().contains("non-sha256"), "{err}");
    }

    #[test]
    fn hash_bytes_is_deterministic() {
        let h1 = hash_bytes(b"hello");
        let h2 = hash_bytes(b"hello");
        assert_eq!(h1, h2);
        assert_eq!(h1.len(), 64);
    }

    #[test]
    fn format_sorts_entries_by_path() {
        // Inputs intentionally out of order — output must be sorted so the
        // manifest content is stable regardless of step ordering.
        let entries = vec![
            ManifestEntry {
                path: PathBuf::from("/z"),
                sha256: "0".repeat(64),
            },
            ManifestEntry {
                path: PathBuf::from("/a"),
                sha256: "1".repeat(64),
            },
            ManifestEntry {
                path: PathBuf::from("/m"),
                sha256: "2".repeat(64),
            },
        ];
        let rendered = format(&entries, &[]);
        let positions: Vec<usize> = ["/a", "/m", "/z"]
            .iter()
            .map(|p| rendered.find(p).expect("path must appear"))
            .collect();
        assert!(positions[0] < positions[1] && positions[1] < positions[2]);
    }

    #[test]
    fn env_round_trips_through_format_and_parse() {
        let entries = vec![ManifestEntry {
            path: PathBuf::from("/svc/file"),
            sha256: "0".repeat(64),
        }];
        let envs = vec![
            EnvEntry {
                key: "FOO".into(),
                value: "bar".into(),
            },
            EnvEntry {
                key: "BAR".into(),
                value: "baz=qux".into(), // value with `=` survives intact
            },
        ];
        let rendered = format(&entries, &envs);
        let (parsed_files, parsed_envs) = parse(&rendered).expect("round-trip");
        assert_eq!(parsed_files.len(), 1);
        assert_eq!(parsed_envs.len(), 2);
        // Sorted by key: BAR before FOO.
        assert_eq!(parsed_envs[0].key, "BAR");
        assert_eq!(parsed_envs[0].value, "baz=qux");
        assert_eq!(parsed_envs[1].key, "FOO");
        assert_eq!(parsed_envs[1].value, "bar");
    }

    #[test]
    fn parse_skips_env_section_header_lines() {
        // Section header is a `# env:` line without `=` in the body — our
        // own format() emits one. It must round-trip without becoming a
        // bogus EnvEntry.
        let input = "# env: static env vars the registry expects in .env\n\
                     # env: KEY=val\n";
        let (files, envs) = parse(input).expect("parse should succeed");
        assert!(files.is_empty());
        assert_eq!(envs.len(), 1);
        assert_eq!(envs[0].key, "KEY");
        assert_eq!(envs[0].value, "val");
    }
}