csaf-core 0.1.0

CSAF storage, validation, sidecar generation, import/export
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Sidecar hash file generation (SHA-256 and SHA3-512).

use std::path::Path;

use sha2::{Digest as Sha2Digest, Sha256};
use sha3::Sha3_512;

use crate::error::Result;

/// Generate SHA-256 hex digest for the given bytes.
#[must_use]
pub fn sha256_hex(data: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(data);
    let result = hasher.finalize();
    hex_encode(&result)
}

/// Generate SHA3-512 hex digest for the given bytes.
#[must_use]
pub fn sha3_512_hex(data: &[u8]) -> String {
    let mut hasher = Sha3_512::new();
    hasher.update(data);
    let result = hasher.finalize();
    hex_encode(&result)
}

/// Generate both SHA-256 and SHA3-512 hex digests.
#[must_use]
pub fn generate_hashes(data: &[u8]) -> (String, String) {
    (sha256_hex(data), sha3_512_hex(data))
}

/// Write sidecar hash files alongside a CSAF JSON file.
///
/// Creates `{path}.sha256` and/or `{path}.sha3-512` files containing
/// the hex digest followed by two spaces and the filename (GNU coreutils format).
///
/// # Errors
///
/// Returns an I/O error if file writing fails.
pub fn write_sidecar_files(
    json_path: &Path,
    data: &[u8],
    write_sha256: bool,
    write_sha3_512: bool,
) -> Result<()> {
    let filename = json_path
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_default();

    if write_sha256 {
        let hash = sha256_hex(data);
        let sidecar_path = json_path.with_extension("json.sha256");
        let content = format!("{hash}  {filename}\n");
        std::fs::write(&sidecar_path, content)?;
    }

    if write_sha3_512 {
        let hash = sha3_512_hex(data);
        let sidecar_path = json_path.with_extension("json.sha3-512");
        let content = format!("{hash}  {filename}\n");
        std::fs::write(&sidecar_path, content)?;
    }

    Ok(())
}

/// Write sidecar hash files for a file with any extension.
///
/// Unlike [`write_sidecar_files`] — which hard-codes `.json.sha256` /
/// `.json.sha3-512` — this helper preserves the file's full extension
/// and appends `.sha256` / `.sha3-512`, e.g. `csaf.redb` →
/// `csaf.redb.sha256`. Used by the database dump pipeline to hash
/// `.redb` and `.sqlite` dumps.
///
/// Returns the sidecar paths that were actually written (in order:
/// sha256, sha3-512).
///
/// # Errors
///
/// Returns an I/O error if file writing fails.
pub fn write_sidecar_files_for(
    file_path: &Path,
    data: &[u8],
    write_sha256: bool,
    write_sha3_512: bool,
) -> Result<(Option<std::path::PathBuf>, Option<std::path::PathBuf>)> {
    let filename = file_path
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_default();

    let mut sha256_path = None;
    let mut sha3_path = None;

    if write_sha256 {
        let hash = sha256_hex(data);
        let mut sidecar = file_path.as_os_str().to_owned();
        sidecar.push(".sha256");
        let sidecar_path = std::path::PathBuf::from(sidecar);
        let content = format!("{hash}  {filename}\n");
        std::fs::write(&sidecar_path, content)?;
        sha256_path = Some(sidecar_path);
    }

    if write_sha3_512 {
        let hash = sha3_512_hex(data);
        let mut sidecar = file_path.as_os_str().to_owned();
        sidecar.push(".sha3-512");
        let sidecar_path = std::path::PathBuf::from(sidecar);
        let content = format!("{hash}  {filename}\n");
        std::fs::write(&sidecar_path, content)?;
        sha3_path = Some(sidecar_path);
    }

    Ok((sha256_path, sha3_path))
}

/// Encode bytes as lowercase hex string.
fn hex_encode(bytes: &[u8]) -> String {
    use std::fmt::Write as _;
    let mut hex = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(hex, "{b:02x}");
    }
    hex
}

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

    #[test]
    fn test_sha256_known_value() {
        // SHA-256 of empty string
        let hash = sha256_hex(b"");
        assert_eq!(
            hash,
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn test_sha256_hello() {
        let hash = sha256_hex(b"hello");
        assert_eq!(
            hash,
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );
    }

    #[test]
    fn test_sha3_512_known_value() {
        // SHA3-512 of empty string
        let hash = sha3_512_hex(b"");
        assert_eq!(
            hash,
            "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615\
             b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
        );
    }

    #[test]
    fn test_generate_hashes() {
        let (sha256, sha3) = generate_hashes(b"test data");
        assert_eq!(sha256.len(), 64); // 256 bits = 32 bytes = 64 hex chars
        assert_eq!(sha3.len(), 128); // 512 bits = 64 bytes = 128 hex chars
    }

    #[test]
    fn test_deterministic() {
        let data = b"CSAF document content";
        let (h1_256, h1_512) = generate_hashes(data);
        let (h2_256, h2_512) = generate_hashes(data);
        assert_eq!(h1_256, h2_256);
        assert_eq!(h1_512, h2_512);
    }

    #[test]
    fn test_write_sidecar_files() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let json_path = dir.path().join("test.json");
        let data = b"{\"test\": true}";
        std::fs::write(&json_path, data).expect("write failed");

        write_sidecar_files(&json_path, data, true, true).expect("sidecar write failed");

        let sha256_path = dir.path().join("test.json.sha256");
        let sha3_path = dir.path().join("test.json.sha3-512");

        assert!(sha256_path.exists());
        assert!(sha3_path.exists());

        let sha256_content = std::fs::read_to_string(&sha256_path).expect("read failed");
        assert!(sha256_content.contains("test.json"));
        assert!(sha256_content.contains("  ")); // GNU format: hash + two spaces + filename
    }

    #[test]
    fn test_write_only_sha256() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let json_path = dir.path().join("test.json");
        let data = b"{}";
        std::fs::write(&json_path, data).expect("write failed");

        write_sidecar_files(&json_path, data, true, false).expect("sidecar write failed");

        assert!(dir.path().join("test.json.sha256").exists());
        assert!(!dir.path().join("test.json.sha3-512").exists());
    }

    #[test]
    fn test_write_sidecar_files_for_redb() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let redb_path = dir.path().join("csaf.redb");
        let data = b"dummy-redb-bytes";
        std::fs::write(&redb_path, data).expect("write failed");

        let (s256, s3) =
            write_sidecar_files_for(&redb_path, data, true, true).expect("sidecar write failed");

        let s256 = s256.expect("sha256 path");
        let s3 = s3.expect("sha3 path");
        assert_eq!(s256.file_name().unwrap(), "csaf.redb.sha256");
        assert_eq!(s3.file_name().unwrap(), "csaf.redb.sha3-512");
        assert!(s256.exists());
        assert!(s3.exists());

        let sha_content = std::fs::read_to_string(&s256).expect("read");
        assert!(sha_content.contains("csaf.redb"));
        assert!(sha_content.contains("  "));
        assert!(sha_content.contains(&sha256_hex(data)));
    }

    #[test]
    fn test_write_sidecar_files_for_skip_one() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let path = dir.path().join("csaf.sqlite");
        let data = b"dummy-sqlite";
        std::fs::write(&path, data).expect("write failed");

        let (s256, s3) = write_sidecar_files_for(&path, data, true, false).expect("sidecar write");
        assert!(s256.is_some());
        assert!(s3.is_none());
        assert!(!dir.path().join("csaf.sqlite.sha3-512").exists());
    }

    #[test]
    fn test_sidecar_matches_csaf_file() {
        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let (sha256, sha3) = generate_hashes(json.as_bytes());

        // Verify hashes are non-empty and correct length.
        assert_eq!(sha256.len(), 64);
        assert_eq!(sha3.len(), 128);

        // Verify determinism with same content.
        let (sha256_again, sha3_again) = generate_hashes(json.as_bytes());
        assert_eq!(sha256, sha256_again);
        assert_eq!(sha3, sha3_again);
    }
}