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