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, SHA-512, and SHA3-512).
5//!
6//! All sidecar filenames use the hyphenated suffix convention mandated by
7//! `CLAUDE.md` §"Cryptographic hashes (release + CSAF)":
8//!
9//! * `.sha-256`   — SHA-2 family, 256-bit
10//! * `.sha-512`   — SHA-2 family, 512-bit
11//! * `.sha3-512`  — SHA-3 family (Keccak), 512-bit
12//!
13//! Shipping three orthogonal algorithms gives defence-in-depth against a
14//! single-family cryptanalytic break.
15
16use std::path::Path;
17
18use sha2::{Digest as Sha2Digest, Sha256, Sha512};
19use sha3::Sha3_512;
20
21// Keep `generic-array` as a direct dep of this crate (see workspace
22// `Cargo.toml` for the rationale). Re-exporting the one type actually
23// baked into the SHA digest outputs that we consume keeps `cargo
24// machete` happy without growing the public API.
25#[doc(hidden)]
26pub use generic_array::typenum::Unsigned as _GenericArrayUnsigned;
27
28use crate::error::Result;
29
30/// Generate SHA-256 hex digest for the given bytes.
31#[must_use]
32pub fn sha256_hex(data: &[u8]) -> String {
33    let mut hasher = Sha256::new();
34    hasher.update(data);
35    let result = hasher.finalize();
36    hex_encode(&result)
37}
38
39/// Generate SHA-512 (SHA-2 family) hex digest for the given bytes.
40#[must_use]
41pub fn sha512_hex(data: &[u8]) -> String {
42    let mut hasher = Sha512::new();
43    hasher.update(data);
44    let result = hasher.finalize();
45    hex_encode(&result)
46}
47
48/// Generate SHA3-512 hex digest for the given bytes.
49#[must_use]
50pub fn sha3_512_hex(data: &[u8]) -> String {
51    let mut hasher = Sha3_512::new();
52    hasher.update(data);
53    let result = hasher.finalize();
54    hex_encode(&result)
55}
56
57/// Generate SHA-256 and SHA3-512 hex digests.
58///
59/// Preserved for backwards compatibility with 0.2.x callers. New code
60/// should prefer [`generate_all_hashes`], which also returns SHA-512.
61#[must_use]
62pub fn generate_hashes(data: &[u8]) -> (String, String) {
63    (sha256_hex(data), sha3_512_hex(data))
64}
65
66/// Generate the full SHA-256 / SHA-512 / SHA3-512 triplet in one pass.
67#[must_use]
68pub fn generate_all_hashes(data: &[u8]) -> (String, String, String) {
69    (sha256_hex(data), sha512_hex(data), sha3_512_hex(data))
70}
71
72/// Write sidecar hash files alongside a CSAF JSON file.
73///
74/// Creates `{path}.sha-256`, `{path}.sha-512`, and/or `{path}.sha3-512`
75/// files containing the hex digest followed by two spaces and the
76/// filename (GNU coreutils format).
77///
78/// # Errors
79///
80/// Returns an I/O error if file writing fails.
81pub fn write_sidecar_files(
82    json_path: &Path,
83    data: &[u8],
84    write_sha256: bool,
85    write_sha512: bool,
86    write_sha3_512: bool,
87) -> Result<()> {
88    let filename = json_path
89        .file_name()
90        .map(|f| f.to_string_lossy().to_string())
91        .unwrap_or_default();
92
93    if write_sha256 {
94        let hash = sha256_hex(data);
95        let sidecar_path = json_path.with_extension("json.sha-256");
96        let content = format!("{hash}  {filename}\n");
97        std::fs::write(&sidecar_path, content)?;
98    }
99
100    if write_sha512 {
101        let hash = sha512_hex(data);
102        let sidecar_path = json_path.with_extension("json.sha-512");
103        let content = format!("{hash}  {filename}\n");
104        std::fs::write(&sidecar_path, content)?;
105    }
106
107    if write_sha3_512 {
108        let hash = sha3_512_hex(data);
109        let sidecar_path = json_path.with_extension("json.sha3-512");
110        let content = format!("{hash}  {filename}\n");
111        std::fs::write(&sidecar_path, content)?;
112    }
113
114    Ok(())
115}
116
117/// Write sidecar hash files for a file with any extension.
118///
119/// Unlike [`write_sidecar_files`] — which hard-codes `.json.sha-256` /
120/// `.json.sha-512` / `.json.sha3-512` — this helper preserves the file's
121/// full extension and appends `.sha-256` / `.sha-512` / `.sha3-512`,
122/// e.g. `csaf.redb` → `csaf.redb.sha-256`. Used by the database dump
123/// pipeline to hash `.redb` and `.sqlite` dumps, and by the audit-log
124/// exporter for `.md` / `.csv` / `.json` / `.sarif` payloads.
125///
126/// Returns the sidecar paths that were actually written (in order:
127/// sha-256, sha-512, sha3-512).
128///
129/// # Errors
130///
131/// Returns an I/O error if file writing fails.
132#[allow(clippy::type_complexity)]
133pub fn write_sidecar_files_for(
134    file_path: &Path,
135    data: &[u8],
136    write_sha256: bool,
137    write_sha512: bool,
138    write_sha3_512: bool,
139) -> Result<(
140    Option<std::path::PathBuf>,
141    Option<std::path::PathBuf>,
142    Option<std::path::PathBuf>,
143)> {
144    let filename = file_path
145        .file_name()
146        .map(|f| f.to_string_lossy().to_string())
147        .unwrap_or_default();
148
149    let mut sha256_path = None;
150    let mut sha512_path = None;
151    let mut sha3_path = None;
152
153    if write_sha256 {
154        let hash = sha256_hex(data);
155        let mut sidecar = file_path.as_os_str().to_owned();
156        sidecar.push(".sha-256");
157        let sidecar_path = std::path::PathBuf::from(sidecar);
158        let content = format!("{hash}  {filename}\n");
159        std::fs::write(&sidecar_path, content)?;
160        sha256_path = Some(sidecar_path);
161    }
162
163    if write_sha512 {
164        let hash = sha512_hex(data);
165        let mut sidecar = file_path.as_os_str().to_owned();
166        sidecar.push(".sha-512");
167        let sidecar_path = std::path::PathBuf::from(sidecar);
168        let content = format!("{hash}  {filename}\n");
169        std::fs::write(&sidecar_path, content)?;
170        sha512_path = Some(sidecar_path);
171    }
172
173    if write_sha3_512 {
174        let hash = sha3_512_hex(data);
175        let mut sidecar = file_path.as_os_str().to_owned();
176        sidecar.push(".sha3-512");
177        let sidecar_path = std::path::PathBuf::from(sidecar);
178        let content = format!("{hash}  {filename}\n");
179        std::fs::write(&sidecar_path, content)?;
180        sha3_path = Some(sidecar_path);
181    }
182
183    Ok((sha256_path, sha512_path, sha3_path))
184}
185
186/// Encode bytes as lowercase hex string.
187fn hex_encode(bytes: &[u8]) -> String {
188    use std::fmt::Write as _;
189    let mut hex = String::with_capacity(bytes.len() * 2);
190    for b in bytes {
191        let _ = write!(hex, "{b:02x}");
192    }
193    hex
194}
195
196#[cfg(test)]
197// Extension comparisons here are intentionally case-sensitive — every
198// sidecar filename is produced by our own writer and thus lowercase.
199#[allow(clippy::case_sensitive_file_extension_comparisons)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_sha256_known_value() {
205        // SHA-256 of empty string
206        let hash = sha256_hex(b"");
207        assert_eq!(
208            hash,
209            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
210        );
211    }
212
213    #[test]
214    fn test_sha256_hello() {
215        let hash = sha256_hex(b"hello");
216        assert_eq!(
217            hash,
218            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
219        );
220    }
221
222    #[test]
223    fn test_sha512_known_value() {
224        // SHA-512 of empty string (NIST test vector).
225        let hash = sha512_hex(b"");
226        assert_eq!(
227            hash,
228            "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\
229             47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
230        );
231    }
232
233    #[test]
234    fn test_sha512_hello() {
235        let hash = sha512_hex(b"hello");
236        assert_eq!(
237            hash,
238            "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7\
239             2323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
240        );
241    }
242
243    #[test]
244    fn test_sha3_512_known_value() {
245        // SHA3-512 of empty string
246        let hash = sha3_512_hex(b"");
247        assert_eq!(
248            hash,
249            "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615\
250             b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
251        );
252    }
253
254    #[test]
255    fn test_generate_hashes_legacy() {
256        let (sha256, sha3) = generate_hashes(b"test data");
257        assert_eq!(sha256.len(), 64); // 256 bits = 32 bytes = 64 hex chars
258        assert_eq!(sha3.len(), 128); // 512 bits = 64 bytes = 128 hex chars
259    }
260
261    #[test]
262    fn test_generate_all_hashes_triplet() {
263        let (sha256, sha512, sha3) = generate_all_hashes(b"test data");
264        assert_eq!(sha256.len(), 64);
265        assert_eq!(sha512.len(), 128);
266        assert_eq!(sha3.len(), 128);
267        // Three distinct digests for the same input.
268        assert_ne!(sha256, sha512);
269        assert_ne!(sha512, sha3);
270        assert_ne!(sha256, sha3);
271    }
272
273    #[test]
274    fn test_deterministic() {
275        let data = b"CSAF document content";
276        let (h1_256, h1_512, h1_3) = generate_all_hashes(data);
277        let (h2_256, h2_512, h2_3) = generate_all_hashes(data);
278        assert_eq!(h1_256, h2_256);
279        assert_eq!(h1_512, h2_512);
280        assert_eq!(h1_3, h2_3);
281    }
282
283    #[test]
284    fn test_write_sidecar_files() {
285        let dir = tempfile::tempdir().expect("tmpdir failed");
286        let json_path = dir.path().join("test.json");
287        let data = b"{\"test\": true}";
288        std::fs::write(&json_path, data).expect("write failed");
289
290        write_sidecar_files(&json_path, data, true, true, true).expect("sidecar write failed");
291
292        let sha256_path = dir.path().join("test.json.sha-256");
293        let sha512_path = dir.path().join("test.json.sha-512");
294        let sha3_path = dir.path().join("test.json.sha3-512");
295
296        assert!(sha256_path.exists());
297        assert!(sha512_path.exists());
298        assert!(sha3_path.exists());
299
300        let sha256_content = std::fs::read_to_string(&sha256_path).expect("read failed");
301        assert!(sha256_content.contains("test.json"));
302        assert!(sha256_content.contains("  ")); // GNU format: hash + two spaces + filename
303
304        // Guard against regression to the unhyphenated legacy form.
305        assert!(!dir.path().join("test.json.sha256").exists());
306        assert!(!dir.path().join("test.json.sha512").exists());
307    }
308
309    #[test]
310    fn test_write_only_sha256() {
311        let dir = tempfile::tempdir().expect("tmpdir failed");
312        let json_path = dir.path().join("test.json");
313        let data = b"{}";
314        std::fs::write(&json_path, data).expect("write failed");
315
316        write_sidecar_files(&json_path, data, true, false, false).expect("sidecar write failed");
317
318        assert!(dir.path().join("test.json.sha-256").exists());
319        assert!(!dir.path().join("test.json.sha-512").exists());
320        assert!(!dir.path().join("test.json.sha3-512").exists());
321    }
322
323    #[test]
324    fn test_write_only_sha512() {
325        let dir = tempfile::tempdir().expect("tmpdir failed");
326        let json_path = dir.path().join("test.json");
327        let data = b"{}";
328        std::fs::write(&json_path, data).expect("write failed");
329
330        write_sidecar_files(&json_path, data, false, true, false).expect("sidecar write failed");
331
332        assert!(!dir.path().join("test.json.sha-256").exists());
333        assert!(dir.path().join("test.json.sha-512").exists());
334        assert!(!dir.path().join("test.json.sha3-512").exists());
335    }
336
337    #[test]
338    fn test_write_sidecar_files_for_redb() {
339        let dir = tempfile::tempdir().expect("tmpdir failed");
340        let redb_path = dir.path().join("csaf.redb");
341        let data = b"dummy-redb-bytes";
342        std::fs::write(&redb_path, data).expect("write failed");
343
344        let (s256, s512, s3) = write_sidecar_files_for(&redb_path, data, true, true, true)
345            .expect("sidecar write failed");
346
347        let s256 = s256.expect("sha256 path");
348        let s512 = s512.expect("sha512 path");
349        let s3 = s3.expect("sha3 path");
350        assert_eq!(s256.file_name().unwrap(), "csaf.redb.sha-256");
351        assert_eq!(s512.file_name().unwrap(), "csaf.redb.sha-512");
352        assert_eq!(s3.file_name().unwrap(), "csaf.redb.sha3-512");
353        assert!(s256.exists());
354        assert!(s512.exists());
355        assert!(s3.exists());
356
357        let sha_content = std::fs::read_to_string(&s256).expect("read");
358        assert!(sha_content.contains("csaf.redb"));
359        assert!(sha_content.contains("  "));
360        assert!(sha_content.contains(&sha256_hex(data)));
361
362        let sha512_content = std::fs::read_to_string(&s512).expect("read");
363        assert!(sha512_content.contains(&sha512_hex(data)));
364    }
365
366    #[test]
367    fn test_write_sidecar_files_for_skip_sha3() {
368        let dir = tempfile::tempdir().expect("tmpdir failed");
369        let path = dir.path().join("csaf.sqlite");
370        let data = b"dummy-sqlite";
371        std::fs::write(&path, data).expect("write failed");
372
373        let (s256, s512, s3) =
374            write_sidecar_files_for(&path, data, true, true, false).expect("sidecar write");
375        assert!(s256.is_some());
376        assert!(s512.is_some());
377        assert!(s3.is_none());
378        assert!(!dir.path().join("csaf.sqlite.sha3-512").exists());
379    }
380
381    #[test]
382    fn test_write_sidecar_files_for_skip_all_but_sha3() {
383        let dir = tempfile::tempdir().expect("tmpdir failed");
384        let path = dir.path().join("evidence.bin");
385        let data = b"x";
386        std::fs::write(&path, data).expect("write failed");
387
388        let (s256, s512, s3) =
389            write_sidecar_files_for(&path, data, false, false, true).expect("sidecar write");
390        assert!(s256.is_none());
391        assert!(s512.is_none());
392        assert!(s3.is_some());
393        assert!(dir.path().join("evidence.bin.sha3-512").exists());
394    }
395
396    #[test]
397    fn test_extension_uses_hyphenated_form_only() {
398        // Regression guard for the 0.3.0 rename: no sidecar may ever be
399        // emitted with the unhyphenated extensions `.sha256` / `.sha512`.
400        let dir = tempfile::tempdir().expect("tmpdir failed");
401        let path = dir.path().join("payload.json");
402        std::fs::write(&path, b"payload").expect("write failed");
403
404        write_sidecar_files(&path, b"payload", true, true, true).expect("sidecar write");
405
406        for entry in std::fs::read_dir(dir.path()).expect("readdir") {
407            let name = entry.unwrap().file_name().to_string_lossy().to_string();
408            // Only the hyphenated forms are legal.
409            assert!(
410                name.ends_with(".json")
411                    || name.ends_with(".json.sha-256")
412                    || name.ends_with(".json.sha-512")
413                    || name.ends_with(".json.sha3-512"),
414                "unexpected sidecar extension: {name}"
415            );
416            assert!(
417                !(name.ends_with(".sha256") || name.ends_with(".sha512")),
418                "legacy unhyphenated form leaked: {name}"
419            );
420        }
421    }
422
423    #[test]
424    fn test_sidecar_matches_csaf_file() {
425        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
426        let (sha256, sha512, sha3) = generate_all_hashes(json.as_bytes());
427
428        // Verify hashes are non-empty and correct length.
429        assert_eq!(sha256.len(), 64);
430        assert_eq!(sha512.len(), 128);
431        assert_eq!(sha3.len(), 128);
432
433        // Verify determinism with same content.
434        let (sha256_again, sha512_again, sha3_again) = generate_all_hashes(json.as_bytes());
435        assert_eq!(sha256, sha256_again);
436        assert_eq!(sha512, sha512_again);
437        assert_eq!(sha3, sha3_again);
438    }
439}