Skip to main content

csaf_core/
sidecar.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Sidecar hash file generation (SHA-256 and SHA3-512).
5
6use std::path::Path;
7
8use sha2::{Digest as Sha2Digest, Sha256};
9use sha3::Sha3_512;
10
11use crate::error::Result;
12
13/// Generate SHA-256 hex digest for the given bytes.
14#[must_use]
15pub fn sha256_hex(data: &[u8]) -> String {
16    let mut hasher = Sha256::new();
17    hasher.update(data);
18    let result = hasher.finalize();
19    hex_encode(&result)
20}
21
22/// Generate SHA3-512 hex digest for the given bytes.
23#[must_use]
24pub fn sha3_512_hex(data: &[u8]) -> String {
25    let mut hasher = Sha3_512::new();
26    hasher.update(data);
27    let result = hasher.finalize();
28    hex_encode(&result)
29}
30
31/// Generate both SHA-256 and SHA3-512 hex digests.
32#[must_use]
33pub fn generate_hashes(data: &[u8]) -> (String, String) {
34    (sha256_hex(data), sha3_512_hex(data))
35}
36
37/// Write sidecar hash files alongside a CSAF JSON file.
38///
39/// Creates `{path}.sha256` and/or `{path}.sha3-512` files containing
40/// the hex digest followed by two spaces and the filename (GNU coreutils format).
41///
42/// # Errors
43///
44/// Returns an I/O error if file writing fails.
45pub fn write_sidecar_files(
46    json_path: &Path,
47    data: &[u8],
48    write_sha256: bool,
49    write_sha3_512: bool,
50) -> Result<()> {
51    let filename = json_path
52        .file_name()
53        .map(|f| f.to_string_lossy().to_string())
54        .unwrap_or_default();
55
56    if write_sha256 {
57        let hash = sha256_hex(data);
58        let sidecar_path = json_path.with_extension("json.sha256");
59        let content = format!("{hash}  {filename}\n");
60        std::fs::write(&sidecar_path, content)?;
61    }
62
63    if write_sha3_512 {
64        let hash = sha3_512_hex(data);
65        let sidecar_path = json_path.with_extension("json.sha3-512");
66        let content = format!("{hash}  {filename}\n");
67        std::fs::write(&sidecar_path, content)?;
68    }
69
70    Ok(())
71}
72
73/// Write sidecar hash files for a file with any extension.
74///
75/// Unlike [`write_sidecar_files`] — which hard-codes `.json.sha256` /
76/// `.json.sha3-512` — this helper preserves the file's full extension
77/// and appends `.sha256` / `.sha3-512`, e.g. `csaf.redb` →
78/// `csaf.redb.sha256`. Used by the database dump pipeline to hash
79/// `.redb` and `.sqlite` dumps.
80///
81/// Returns the sidecar paths that were actually written (in order:
82/// sha256, sha3-512).
83///
84/// # Errors
85///
86/// Returns an I/O error if file writing fails.
87pub fn write_sidecar_files_for(
88    file_path: &Path,
89    data: &[u8],
90    write_sha256: bool,
91    write_sha3_512: bool,
92) -> Result<(Option<std::path::PathBuf>, Option<std::path::PathBuf>)> {
93    let filename = file_path
94        .file_name()
95        .map(|f| f.to_string_lossy().to_string())
96        .unwrap_or_default();
97
98    let mut sha256_path = None;
99    let mut sha3_path = None;
100
101    if write_sha256 {
102        let hash = sha256_hex(data);
103        let mut sidecar = file_path.as_os_str().to_owned();
104        sidecar.push(".sha256");
105        let sidecar_path = std::path::PathBuf::from(sidecar);
106        let content = format!("{hash}  {filename}\n");
107        std::fs::write(&sidecar_path, content)?;
108        sha256_path = Some(sidecar_path);
109    }
110
111    if write_sha3_512 {
112        let hash = sha3_512_hex(data);
113        let mut sidecar = file_path.as_os_str().to_owned();
114        sidecar.push(".sha3-512");
115        let sidecar_path = std::path::PathBuf::from(sidecar);
116        let content = format!("{hash}  {filename}\n");
117        std::fs::write(&sidecar_path, content)?;
118        sha3_path = Some(sidecar_path);
119    }
120
121    Ok((sha256_path, sha3_path))
122}
123
124/// Encode bytes as lowercase hex string.
125fn hex_encode(bytes: &[u8]) -> String {
126    use std::fmt::Write as _;
127    let mut hex = String::with_capacity(bytes.len() * 2);
128    for b in bytes {
129        let _ = write!(hex, "{b:02x}");
130    }
131    hex
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_sha256_known_value() {
140        // SHA-256 of empty string
141        let hash = sha256_hex(b"");
142        assert_eq!(
143            hash,
144            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
145        );
146    }
147
148    #[test]
149    fn test_sha256_hello() {
150        let hash = sha256_hex(b"hello");
151        assert_eq!(
152            hash,
153            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
154        );
155    }
156
157    #[test]
158    fn test_sha3_512_known_value() {
159        // SHA3-512 of empty string
160        let hash = sha3_512_hex(b"");
161        assert_eq!(
162            hash,
163            "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615\
164             b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
165        );
166    }
167
168    #[test]
169    fn test_generate_hashes() {
170        let (sha256, sha3) = generate_hashes(b"test data");
171        assert_eq!(sha256.len(), 64); // 256 bits = 32 bytes = 64 hex chars
172        assert_eq!(sha3.len(), 128); // 512 bits = 64 bytes = 128 hex chars
173    }
174
175    #[test]
176    fn test_deterministic() {
177        let data = b"CSAF document content";
178        let (h1_256, h1_512) = generate_hashes(data);
179        let (h2_256, h2_512) = generate_hashes(data);
180        assert_eq!(h1_256, h2_256);
181        assert_eq!(h1_512, h2_512);
182    }
183
184    #[test]
185    fn test_write_sidecar_files() {
186        let dir = tempfile::tempdir().expect("tmpdir failed");
187        let json_path = dir.path().join("test.json");
188        let data = b"{\"test\": true}";
189        std::fs::write(&json_path, data).expect("write failed");
190
191        write_sidecar_files(&json_path, data, true, true).expect("sidecar write failed");
192
193        let sha256_path = dir.path().join("test.json.sha256");
194        let sha3_path = dir.path().join("test.json.sha3-512");
195
196        assert!(sha256_path.exists());
197        assert!(sha3_path.exists());
198
199        let sha256_content = std::fs::read_to_string(&sha256_path).expect("read failed");
200        assert!(sha256_content.contains("test.json"));
201        assert!(sha256_content.contains("  ")); // GNU format: hash + two spaces + filename
202    }
203
204    #[test]
205    fn test_write_only_sha256() {
206        let dir = tempfile::tempdir().expect("tmpdir failed");
207        let json_path = dir.path().join("test.json");
208        let data = b"{}";
209        std::fs::write(&json_path, data).expect("write failed");
210
211        write_sidecar_files(&json_path, data, true, false).expect("sidecar write failed");
212
213        assert!(dir.path().join("test.json.sha256").exists());
214        assert!(!dir.path().join("test.json.sha3-512").exists());
215    }
216
217    #[test]
218    fn test_write_sidecar_files_for_redb() {
219        let dir = tempfile::tempdir().expect("tmpdir failed");
220        let redb_path = dir.path().join("csaf.redb");
221        let data = b"dummy-redb-bytes";
222        std::fs::write(&redb_path, data).expect("write failed");
223
224        let (s256, s3) =
225            write_sidecar_files_for(&redb_path, data, true, true).expect("sidecar write failed");
226
227        let s256 = s256.expect("sha256 path");
228        let s3 = s3.expect("sha3 path");
229        assert_eq!(s256.file_name().unwrap(), "csaf.redb.sha256");
230        assert_eq!(s3.file_name().unwrap(), "csaf.redb.sha3-512");
231        assert!(s256.exists());
232        assert!(s3.exists());
233
234        let sha_content = std::fs::read_to_string(&s256).expect("read");
235        assert!(sha_content.contains("csaf.redb"));
236        assert!(sha_content.contains("  "));
237        assert!(sha_content.contains(&sha256_hex(data)));
238    }
239
240    #[test]
241    fn test_write_sidecar_files_for_skip_one() {
242        let dir = tempfile::tempdir().expect("tmpdir failed");
243        let path = dir.path().join("csaf.sqlite");
244        let data = b"dummy-sqlite";
245        std::fs::write(&path, data).expect("write failed");
246
247        let (s256, s3) = write_sidecar_files_for(&path, data, true, false).expect("sidecar write");
248        assert!(s256.is_some());
249        assert!(s3.is_none());
250        assert!(!dir.path().join("csaf.sqlite.sha3-512").exists());
251    }
252
253    #[test]
254    fn test_sidecar_matches_csaf_file() {
255        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
256        let (sha256, sha3) = generate_hashes(json.as_bytes());
257
258        // Verify hashes are non-empty and correct length.
259        assert_eq!(sha256.len(), 64);
260        assert_eq!(sha3.len(), 128);
261
262        // Verify determinism with same content.
263        let (sha256_again, sha3_again) = generate_hashes(json.as_bytes());
264        assert_eq!(sha256, sha256_again);
265        assert_eq!(sha3, sha3_again);
266    }
267}