polykit_core/remote_cache/
integrity.rs

1//! Integrity verification for cached artifacts.
2
3use std::io::Read;
4use std::path::Path;
5
6use sha2::{Digest, Sha256};
7
8use crate::error::{Error, Result};
9
10use super::artifact::Artifact;
11
12/// Verifies the integrity of an artifact.
13///
14/// Checks:
15/// - SHA-256 hash of compressed data
16/// - Manifest file hashes match actual file contents
17/// - File sizes match manifest
18pub struct ArtifactVerifier;
19
20impl ArtifactVerifier {
21    /// Verifies the integrity of an artifact.
22    ///
23    /// # Arguments
24    ///
25    /// * `artifact` - The artifact to verify
26    /// * `expected_hash` - Optional expected hash of the compressed artifact
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if verification fails.
31    pub fn verify(artifact: &Artifact, expected_hash: Option<&str>) -> Result<()> {
32        // Verify compressed data hash if provided
33        if let Some(expected) = expected_hash {
34            let actual_hash = artifact.hash();
35            if actual_hash != expected {
36                return Err(Error::Adapter {
37                    package: "artifact-verification".to_string(),
38                    message: format!(
39                        "Artifact hash mismatch: expected {}, got {}",
40                        expected, actual_hash
41                    ),
42                });
43            }
44        }
45
46        // Verify manifest integrity by checking file hashes
47        Self::verify_manifest(artifact)?;
48
49        Ok(())
50    }
51
52    /// Verifies that the manifest matches the actual file contents.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if any file hash doesn't match.
57    fn verify_manifest(artifact: &Artifact) -> Result<()> {
58        use tar::Archive;
59
60        // Decompress
61        let tar_data = zstd::decode_all(artifact.compressed_data()).map_err(|e| Error::Adapter {
62            package: "artifact-verification".to_string(),
63            message: format!("Failed to decompress artifact: {}", e),
64        })?;
65
66        // Extract and verify files
67        let mut archive = Archive::new(&tar_data[..]);
68        let outputs_dir = Path::new("outputs");
69        let manifest = artifact.manifest();
70
71        for entry_result in archive.entries().map_err(|e| Error::Adapter {
72            package: "artifact-verification".to_string(),
73            message: format!("Failed to read tar archive: {}", e),
74        })? {
75            let mut entry = entry_result.map_err(|e| Error::Adapter {
76                package: "artifact-verification".to_string(),
77                message: format!("Failed to read tar entry: {}", e),
78            })?;
79
80            let path = entry.path().map_err(|e| Error::Adapter {
81                package: "artifact-verification".to_string(),
82                message: format!("Failed to get entry path: {}", e),
83            })?;
84
85            // Skip metadata and manifest
86            if path == Path::new("metadata.json") || path == Path::new("manifest.json") {
87                continue;
88            }
89
90            // Verify output files
91            if let Ok(relative_path) = path.strip_prefix(outputs_dir) {
92                let expected_hash = manifest.files.get(relative_path).ok_or_else(|| {
93                    Error::Adapter {
94                        package: "artifact-verification".to_string(),
95                        message: format!(
96                            "File {} in artifact but not in manifest",
97                            relative_path.display()
98                        ),
99                    }
100                })?;
101
102                // Read file content (need to get path first, then read)
103                let relative_path = relative_path.to_path_buf();
104                let mut content = Vec::new();
105                entry.read_to_end(&mut content).map_err(|e| Error::Adapter {
106                    package: "artifact-verification".to_string(),
107                    message: format!("Failed to read file content: {}", e),
108                })?;
109
110                // Compute hash
111                let mut hasher = Sha256::new();
112                hasher.update(&content);
113                let actual_hash = format!("{:x}", hasher.finalize());
114
115                if actual_hash != *expected_hash {
116                    return Err(Error::Adapter {
117                        package: "artifact-verification".to_string(),
118                        message: format!(
119                            "File {} hash mismatch: expected {}, got {}",
120                            relative_path.display(),
121                            expected_hash,
122                            actual_hash
123                        ),
124                    });
125                }
126            }
127        }
128
129        // All files have been verified during iteration above
130
131        Ok(())
132    }
133
134    /// Verifies file size matches manifest.
135    ///
136    /// This is a lightweight check that can be done without extracting files.
137    pub fn verify_size(artifact: &Artifact, max_size: u64) -> Result<()> {
138        let manifest = artifact.manifest();
139        if manifest.total_size > max_size {
140            return Err(Error::Adapter {
141                package: "artifact-verification".to_string(),
142                message: format!(
143                    "Artifact size {} exceeds maximum {}",
144                    manifest.total_size, max_size
145                ),
146            });
147        }
148        Ok(())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::remote_cache::artifact::Artifact;
156    use std::collections::BTreeMap;
157    use std::path::PathBuf;
158
159    #[test]
160    fn test_verify_valid_artifact() {
161        let mut output_files = BTreeMap::new();
162        output_files.insert(PathBuf::from("file.txt"), b"content".to_vec());
163
164        let artifact = Artifact::new(
165            "test".to_string(),
166            "build".to_string(),
167            "echo".to_string(),
168            "hash123".to_string(),
169            output_files,
170        )
171        .unwrap();
172
173        // Should verify successfully
174        assert!(ArtifactVerifier::verify(&artifact, None).is_ok());
175    }
176
177    #[test]
178    fn test_verify_hash_mismatch() {
179        let mut output_files = BTreeMap::new();
180        output_files.insert(PathBuf::from("file.txt"), b"content".to_vec());
181
182        let artifact = Artifact::new(
183            "test".to_string(),
184            "build".to_string(),
185            "echo".to_string(),
186            "hash123".to_string(),
187            output_files,
188        )
189        .unwrap();
190
191        // Should fail with wrong hash
192        assert!(ArtifactVerifier::verify(&artifact, Some("wrong_hash")).is_err());
193    }
194}