clnrm_core/reporting/
digest.rs

1//! SHA-256 digest for reproducibility
2//!
3//! Generates cryptographic hashes of span data to ensure reproducible test results.
4
5use crate::error::{CleanroomError, Result};
6use sha2::{Digest, Sha256};
7use std::path::Path;
8
9/// SHA-256 digest generator for reproducibility
10pub struct DigestReporter;
11
12impl DigestReporter {
13    /// Write SHA-256 digest to file
14    ///
15    /// # Arguments
16    /// * `path` - File path for digest output
17    /// * `spans_json` - JSON string of spans to hash
18    ///
19    /// # Returns
20    /// * `Result<()>` - Success or error
21    ///
22    /// # Errors
23    /// Returns error if file write fails
24    pub fn write(path: &Path, spans_json: &str) -> Result<()> {
25        let digest = Self::compute_digest(spans_json);
26        Self::write_file(path, &digest)
27    }
28
29    /// Compute SHA-256 digest of input string
30    ///
31    /// # Arguments
32    /// * `spans_json` - JSON string to hash
33    ///
34    /// # Returns
35    /// * Hexadecimal string representation of SHA-256 hash
36    pub fn compute_digest(spans_json: &str) -> String {
37        let mut hasher = Sha256::new();
38        hasher.update(spans_json.as_bytes());
39        format!("{:x}", hasher.finalize())
40    }
41
42    /// Write digest to file with newline
43    fn write_file(path: &Path, digest: &str) -> Result<()> {
44        std::fs::write(path, format!("{}\n", digest))
45            .map_err(|e| CleanroomError::report_error(format!("Failed to write digest: {}", e)))
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use tempfile::TempDir;
53
54    #[test]
55    fn test_digest_reporter_basic() -> Result<()> {
56        // Arrange
57        let temp_dir = TempDir::new()
58            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
59        let digest_path = temp_dir.path().join("digest.txt");
60        let spans_json = r#"{"spans": []}"#;
61
62        // Act
63        DigestReporter::write(&digest_path, spans_json)?;
64
65        // Assert
66        let content = std::fs::read_to_string(&digest_path)
67            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
68
69        // SHA-256 produces 64 hex characters
70        assert_eq!(content.trim().len(), 64);
71        assert!(content.chars().all(|c| c.is_ascii_hexdigit() || c == '\n'));
72
73        Ok(())
74    }
75
76    #[test]
77    fn test_digest_reporter_deterministic() -> Result<()> {
78        // Arrange
79        let temp_dir = TempDir::new()
80            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
81        let digest_path1 = temp_dir.path().join("digest1.txt");
82        let digest_path2 = temp_dir.path().join("digest2.txt");
83        let spans_json = r#"{"spans": [{"name": "test"}]}"#;
84
85        // Act
86        DigestReporter::write(&digest_path1, spans_json)?;
87        DigestReporter::write(&digest_path2, spans_json)?;
88
89        // Assert
90        let content1 = std::fs::read_to_string(&digest_path1)
91            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
92        let content2 = std::fs::read_to_string(&digest_path2)
93            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
94
95        assert_eq!(content1, content2);
96
97        Ok(())
98    }
99
100    #[test]
101    fn test_digest_reporter_different_inputs() -> Result<()> {
102        // Arrange
103        let temp_dir = TempDir::new()
104            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
105        let digest_path1 = temp_dir.path().join("digest1.txt");
106        let digest_path2 = temp_dir.path().join("digest2.txt");
107        let spans_json1 = r#"{"spans": []}"#;
108        let spans_json2 = r#"{"spans": [{"name": "test"}]}"#;
109
110        // Act
111        DigestReporter::write(&digest_path1, spans_json1)?;
112        DigestReporter::write(&digest_path2, spans_json2)?;
113
114        // Assert
115        let content1 = std::fs::read_to_string(&digest_path1)
116            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
117        let content2 = std::fs::read_to_string(&digest_path2)
118            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
119
120        assert_ne!(content1, content2);
121
122        Ok(())
123    }
124
125    #[test]
126    fn test_compute_digest_known_value() {
127        // Arrange
128        let input = "test";
129        // Pre-computed SHA-256 hash of "test"
130        let expected = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
131
132        // Act
133        let digest = DigestReporter::compute_digest(input);
134
135        // Assert
136        assert_eq!(digest, expected);
137    }
138
139    #[test]
140    fn test_compute_digest_empty_string() {
141        // Arrange
142        let input = "";
143        // Pre-computed SHA-256 hash of empty string
144        let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
145
146        // Act
147        let digest = DigestReporter::compute_digest(input);
148
149        // Assert
150        assert_eq!(digest, expected);
151    }
152
153    #[test]
154    fn test_compute_digest_complex_json() {
155        // Arrange
156        let input = r#"{"spans":[{"name":"root","span_id":"1"},{"name":"child","span_id":"2"}]}"#;
157
158        // Act
159        let digest = DigestReporter::compute_digest(input);
160
161        // Assert
162        assert_eq!(digest.len(), 64);
163        assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
164    }
165
166    #[test]
167    fn test_digest_sensitivity_to_whitespace() {
168        // Arrange
169        let input1 = r#"{"spans": []}"#;
170        let input2 = r#"{"spans":[]}"#; // No space after colon
171
172        // Act
173        let digest1 = DigestReporter::compute_digest(input1);
174        let digest2 = DigestReporter::compute_digest(input2);
175
176        // Assert
177        assert_ne!(digest1, digest2, "Digest should be sensitive to whitespace");
178    }
179
180    #[test]
181    fn test_digest_file_format() -> Result<()> {
182        // Arrange
183        let temp_dir = TempDir::new()
184            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
185        let digest_path = temp_dir.path().join("digest.txt");
186        let spans_json = "test";
187
188        // Act
189        DigestReporter::write(&digest_path, spans_json)?;
190
191        // Assert
192        let content = std::fs::read_to_string(&digest_path)
193            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
194
195        // Should have exactly one newline at the end
196        assert!(content.ends_with('\n'));
197        assert_eq!(content.lines().count(), 1);
198
199        Ok(())
200    }
201}