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 csaf_models::settings::Settings;
17use sha2::{Digest as Sha2Digest, Sha256, Sha512};
18use sha3::Sha3_512;
19
20use crate::fs::DataDir;
21
22// Keep `generic-array` as a direct dep of this crate (see workspace
23// `Cargo.toml` for the rationale). Re-exporting the one type actually
24// baked into the SHA digest outputs that we consume keeps `cargo
25// machete` happy without growing the public API.
26#[doc(hidden)]
27pub use generic_array::typenum::Unsigned as _GenericArrayUnsigned;
28
29use crate::error::Result;
30
31/// Generate SHA-256 hex digest for the given bytes.
32#[must_use]
33pub fn sha256_hex(data: &[u8]) -> String {
34    let mut hasher = Sha256::new();
35    hasher.update(data);
36    let result = hasher.finalize();
37    hex_encode(&result)
38}
39
40/// Generate SHA-512 (SHA-2 family) hex digest for the given bytes.
41#[must_use]
42pub fn sha512_hex(data: &[u8]) -> String {
43    let mut hasher = Sha512::new();
44    hasher.update(data);
45    let result = hasher.finalize();
46    hex_encode(&result)
47}
48
49/// Generate SHA3-512 hex digest for the given bytes.
50#[must_use]
51pub fn sha3_512_hex(data: &[u8]) -> String {
52    let mut hasher = Sha3_512::new();
53    hasher.update(data);
54    let result = hasher.finalize();
55    hex_encode(&result)
56}
57
58/// Generate SHA-256 and SHA3-512 hex digests.
59///
60/// Preserved for backwards compatibility with 0.2.x callers. New code
61/// should prefer [`generate_all_hashes`], which also returns SHA-512.
62#[must_use]
63pub fn generate_hashes(data: &[u8]) -> (String, String) {
64    (sha256_hex(data), sha3_512_hex(data))
65}
66
67/// Generate the full SHA-256 / SHA-512 / SHA3-512 triplet in one pass.
68#[must_use]
69pub fn generate_all_hashes(data: &[u8]) -> (String, String, String) {
70    (sha256_hex(data), sha512_hex(data), sha3_512_hex(data))
71}
72
73/// Selection of hash sidecar families to emit.
74///
75/// An options object keeps the writer functions within the parameter
76/// budget (CLAUDE.md Pattern 3) and carries the choice from the
77/// `Settings` toggles to the writer as one value.
78#[derive(Debug, Clone, Copy)]
79pub struct SidecarHashes {
80    /// Emit the SHA-256 (`.sha-256`) sidecar.
81    pub sha256: bool,
82    /// Emit the SHA-512 (`.sha-512`) sidecar.
83    pub sha512: bool,
84    /// Emit the SHA3-512 (`.sha3-512`) sidecar.
85    pub sha3_512: bool,
86}
87
88impl SidecarHashes {
89    /// Build the selection from the matching [`Settings`] toggles.
90    #[must_use]
91    pub const fn from_settings(settings: &Settings) -> Self {
92        Self {
93            sha256: settings.sidecar_sha256,
94            sha512: settings.sidecar_sha512,
95            sha3_512: settings.sidecar_sha3_512,
96        }
97    }
98}
99
100/// Write hash sidecars alongside a CSAF JSON file.
101///
102/// `rel` is the artefact's path relative to the capability-scoped `dir`
103/// (e.g. `2026/001/ndaal-sa-2026-001.json`). Creates `{rel}.sha-256`,
104/// `{rel}.sha-512`, and/or `{rel}.sha3-512` inside `dir` per `hashes`,
105/// each holding the hex digest, two spaces, and the artefact filename
106/// (`shasum`-compatible format). All writes are confined to `dir`.
107///
108/// # Errors
109///
110/// Returns an I/O error if file writing fails.
111pub fn write_sidecar_files(
112    dir: &DataDir,
113    rel: &str,
114    data: &[u8],
115    hashes: SidecarHashes,
116) -> Result<()> {
117    let filename = rel.rsplit('/').next().unwrap_or(rel);
118
119    if hashes.sha256 {
120        dir.write(
121            &format!("{rel}.sha-256"),
122            format!("{}  {filename}\n", sha256_hex(data)).as_bytes(),
123        )?;
124    }
125
126    if hashes.sha512 {
127        dir.write(
128            &format!("{rel}.sha-512"),
129            format!("{}  {filename}\n", sha512_hex(data)).as_bytes(),
130        )?;
131    }
132
133    if hashes.sha3_512 {
134        dir.write(
135            &format!("{rel}.sha3-512"),
136            format!("{}  {filename}\n", sha3_512_hex(data)).as_bytes(),
137        )?;
138    }
139
140    Ok(())
141}
142
143/// Write hash sidecars for an artefact, preserving its full extension.
144///
145/// Unlike [`write_sidecar_files`], this appends `.sha-256` / `.sha-512` /
146/// `.sha3-512` to the artefact's full name (`csaf.redb` →
147/// `csaf.redb.sha-256`). `rel` is confined to `dir`; used by the
148/// database-dump pipeline and the audit-log exporter. Returns the
149/// relative paths actually written (sha-256, sha-512, sha3-512).
150///
151/// # Errors
152///
153/// Returns an I/O error if file writing fails.
154pub fn write_sidecar_files_for(
155    dir: &DataDir,
156    rel: &str,
157    data: &[u8],
158    hashes: SidecarHashes,
159) -> Result<Vec<String>> {
160    let filename = rel.rsplit('/').next().unwrap_or(rel);
161    let mut written = Vec::new();
162
163    if hashes.sha256 {
164        let sidecar = format!("{rel}.sha-256");
165        dir.write(
166            &sidecar,
167            format!("{}  {filename}\n", sha256_hex(data)).as_bytes(),
168        )?;
169        written.push(sidecar);
170    }
171
172    if hashes.sha512 {
173        let sidecar = format!("{rel}.sha-512");
174        dir.write(
175            &sidecar,
176            format!("{}  {filename}\n", sha512_hex(data)).as_bytes(),
177        )?;
178        written.push(sidecar);
179    }
180
181    if hashes.sha3_512 {
182        let sidecar = format!("{rel}.sha3-512");
183        dir.write(
184            &sidecar,
185            format!("{}  {filename}\n", sha3_512_hex(data)).as_bytes(),
186        )?;
187        written.push(sidecar);
188    }
189
190    Ok(written)
191}
192
193/// Encode bytes as lowercase hex string.
194fn hex_encode(bytes: &[u8]) -> String {
195    use std::fmt::Write as _;
196    let mut hex = String::with_capacity(bytes.len() * 2);
197    for b in bytes {
198        let _ = write!(hex, "{b:02x}");
199    }
200    hex
201}
202
203#[cfg(test)]
204// Extension comparisons here are intentionally case-sensitive — every
205// sidecar filename is produced by our own writer and thus lowercase.
206#[allow(clippy::case_sensitive_file_extension_comparisons)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_sha256_known_value() {
212        // SHA-256 of empty string
213        let hash = sha256_hex(b"");
214        assert_eq!(
215            hash,
216            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
217        );
218    }
219
220    #[test]
221    fn test_sha256_hello() {
222        let hash = sha256_hex(b"hello");
223        assert_eq!(
224            hash,
225            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
226        );
227    }
228
229    #[test]
230    fn test_sha512_known_value() {
231        // SHA-512 of empty string (NIST test vector).
232        let hash = sha512_hex(b"");
233        assert_eq!(
234            hash,
235            "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\
236             47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
237        );
238    }
239
240    #[test]
241    fn test_sha512_hello() {
242        let hash = sha512_hex(b"hello");
243        assert_eq!(
244            hash,
245            "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7\
246             2323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
247        );
248    }
249
250    #[test]
251    fn test_sha3_512_known_value() {
252        // SHA3-512 of empty string
253        let hash = sha3_512_hex(b"");
254        assert_eq!(
255            hash,
256            "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615\
257             b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
258        );
259    }
260
261    #[test]
262    fn test_generate_hashes_legacy() {
263        let (sha256, sha3) = generate_hashes(b"test data");
264        assert_eq!(sha256.len(), 64); // 256 bits = 32 bytes = 64 hex chars
265        assert_eq!(sha3.len(), 128); // 512 bits = 64 bytes = 128 hex chars
266    }
267
268    #[test]
269    fn test_generate_all_hashes_triplet() {
270        let (sha256, sha512, sha3) = generate_all_hashes(b"test data");
271        assert_eq!(sha256.len(), 64);
272        assert_eq!(sha512.len(), 128);
273        assert_eq!(sha3.len(), 128);
274        // Three distinct digests for the same input.
275        assert_ne!(sha256, sha512);
276        assert_ne!(sha512, sha3);
277        assert_ne!(sha256, sha3);
278    }
279
280    #[test]
281    fn test_deterministic() {
282        let data = b"CSAF document content";
283        let (h1_256, h1_512, h1_3) = generate_all_hashes(data);
284        let (h2_256, h2_512, h2_3) = generate_all_hashes(data);
285        assert_eq!(h1_256, h2_256);
286        assert_eq!(h1_512, h2_512);
287        assert_eq!(h1_3, h2_3);
288    }
289
290    #[test]
291    fn test_write_sidecar_files() {
292        let dir = tempfile::tempdir().expect("tmpdir failed");
293        let data = b"{\"test\": true}";
294        let dd = DataDir::open(dir.path()).expect("open base");
295        dd.write("test.json", data).expect("write json");
296
297        write_sidecar_files(
298            &dd,
299            "test.json",
300            data,
301            SidecarHashes {
302                sha256: true,
303                sha512: true,
304                sha3_512: true,
305            },
306        )
307        .expect("sidecar write failed");
308
309        let sha256_path = dir.path().join("test.json.sha-256");
310        let sha512_path = dir.path().join("test.json.sha-512");
311        let sha3_path = dir.path().join("test.json.sha3-512");
312
313        assert!(sha256_path.exists());
314        assert!(sha512_path.exists());
315        assert!(sha3_path.exists());
316
317        let sha256_content = std::fs::read_to_string(&sha256_path).expect("read failed");
318        assert!(sha256_content.contains("test.json"));
319        assert!(sha256_content.contains("  ")); // GNU format: hash + two spaces + filename
320
321        // Guard against regression to the unhyphenated legacy form.
322        assert!(!dir.path().join("test.json.sha256").exists());
323        assert!(!dir.path().join("test.json.sha512").exists());
324    }
325
326    #[test]
327    fn test_write_only_sha256() {
328        let dir = tempfile::tempdir().expect("tmpdir failed");
329        let data = b"{}";
330        let dd = DataDir::open(dir.path()).expect("open base");
331        dd.write("test.json", data).expect("write json");
332
333        write_sidecar_files(
334            &dd,
335            "test.json",
336            data,
337            SidecarHashes {
338                sha256: true,
339                sha512: false,
340                sha3_512: false,
341            },
342        )
343        .expect("sidecar write failed");
344
345        assert!(dir.path().join("test.json.sha-256").exists());
346        assert!(!dir.path().join("test.json.sha-512").exists());
347        assert!(!dir.path().join("test.json.sha3-512").exists());
348    }
349
350    #[test]
351    fn test_write_only_sha512() {
352        let dir = tempfile::tempdir().expect("tmpdir failed");
353        let data = b"{}";
354        let dd = DataDir::open(dir.path()).expect("open base");
355        dd.write("test.json", data).expect("write json");
356
357        write_sidecar_files(
358            &dd,
359            "test.json",
360            data,
361            SidecarHashes {
362                sha256: false,
363                sha512: true,
364                sha3_512: false,
365            },
366        )
367        .expect("sidecar write failed");
368
369        assert!(!dir.path().join("test.json.sha-256").exists());
370        assert!(dir.path().join("test.json.sha-512").exists());
371        assert!(!dir.path().join("test.json.sha3-512").exists());
372    }
373
374    #[test]
375    fn test_write_sidecar_files_for_redb() {
376        let dir = tempfile::tempdir().expect("tmpdir failed");
377        let data = b"dummy-redb-bytes";
378        let dd = DataDir::open(dir.path()).expect("open base");
379        dd.write("csaf.redb", data).expect("write redb");
380
381        let written = write_sidecar_files_for(
382            &dd,
383            "csaf.redb",
384            data,
385            SidecarHashes {
386                sha256: true,
387                sha512: true,
388                sha3_512: true,
389            },
390        )
391        .expect("sidecar write failed");
392
393        assert_eq!(
394            written,
395            vec![
396                "csaf.redb.sha-256".to_string(),
397                "csaf.redb.sha-512".to_string(),
398                "csaf.redb.sha3-512".to_string(),
399            ]
400        );
401        assert!(dir.path().join("csaf.redb.sha-256").exists());
402        assert!(dir.path().join("csaf.redb.sha-512").exists());
403        assert!(dir.path().join("csaf.redb.sha3-512").exists());
404
405        let sha_content =
406            std::fs::read_to_string(dir.path().join("csaf.redb.sha-256")).expect("read");
407        assert!(sha_content.contains("csaf.redb"));
408        assert!(sha_content.contains("  "));
409        assert!(sha_content.contains(&sha256_hex(data)));
410
411        let sha512_content =
412            std::fs::read_to_string(dir.path().join("csaf.redb.sha-512")).expect("read");
413        assert!(sha512_content.contains(&sha512_hex(data)));
414    }
415
416    #[test]
417    fn test_write_sidecar_files_for_skip_sha3() {
418        let dir = tempfile::tempdir().expect("tmpdir failed");
419        let data = b"dummy-sqlite";
420        let dd = DataDir::open(dir.path()).expect("open base");
421        dd.write("csaf.sqlite", data).expect("write sqlite");
422
423        let written = write_sidecar_files_for(
424            &dd,
425            "csaf.sqlite",
426            data,
427            SidecarHashes {
428                sha256: true,
429                sha512: true,
430                sha3_512: false,
431            },
432        )
433        .expect("sidecar write");
434        assert_eq!(
435            written,
436            vec![
437                "csaf.sqlite.sha-256".to_string(),
438                "csaf.sqlite.sha-512".to_string(),
439            ]
440        );
441        assert!(!dir.path().join("csaf.sqlite.sha3-512").exists());
442    }
443
444    #[test]
445    fn test_write_sidecar_files_for_skip_all_but_sha3() {
446        let dir = tempfile::tempdir().expect("tmpdir failed");
447        let data = b"x";
448        let dd = DataDir::open(dir.path()).expect("open base");
449        dd.write("evidence.bin", data).expect("write evidence");
450
451        let written = write_sidecar_files_for(
452            &dd,
453            "evidence.bin",
454            data,
455            SidecarHashes {
456                sha256: false,
457                sha512: false,
458                sha3_512: true,
459            },
460        )
461        .expect("sidecar write");
462        assert_eq!(written, vec!["evidence.bin.sha3-512".to_string()]);
463        assert!(dir.path().join("evidence.bin.sha3-512").exists());
464    }
465
466    #[test]
467    fn test_extension_uses_hyphenated_form_only() {
468        // Regression guard for the 0.3.0 rename: no sidecar may ever be
469        // emitted with the unhyphenated extensions `.sha256` / `.sha512`.
470        let dir = tempfile::tempdir().expect("tmpdir failed");
471        let dd = DataDir::open(dir.path()).expect("open base");
472        dd.write("payload.json", b"payload").expect("write json");
473
474        write_sidecar_files(
475            &dd,
476            "payload.json",
477            b"payload",
478            SidecarHashes {
479                sha256: true,
480                sha512: true,
481                sha3_512: true,
482            },
483        )
484        .expect("sidecar write");
485
486        for entry in std::fs::read_dir(dir.path()).expect("readdir") {
487            let name = entry.unwrap().file_name().to_string_lossy().to_string();
488            // Only the hyphenated forms are legal.
489            assert!(
490                name.ends_with(".json")
491                    || name.ends_with(".json.sha-256")
492                    || name.ends_with(".json.sha-512")
493                    || name.ends_with(".json.sha3-512"),
494                "unexpected sidecar extension: {name}"
495            );
496            assert!(
497                !(name.ends_with(".sha256") || name.ends_with(".sha512")),
498                "legacy unhyphenated form leaked: {name}"
499            );
500        }
501    }
502
503    #[test]
504    fn test_sidecar_matches_csaf_file() {
505        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
506        let (sha256, sha512, sha3) = generate_all_hashes(json.as_bytes());
507
508        // Verify hashes are non-empty and correct length.
509        assert_eq!(sha256.len(), 64);
510        assert_eq!(sha512.len(), 128);
511        assert_eq!(sha3.len(), 128);
512
513        // Verify determinism with same content.
514        let (sha256_again, sha512_again, sha3_again) = generate_all_hashes(json.as_bytes());
515        assert_eq!(sha256, sha256_again);
516        assert_eq!(sha512, sha512_again);
517        assert_eq!(sha3, sha3_again);
518    }
519}