polykit_core/remote_cache/
artifact.rs

1//! Artifact format for cached task outputs.
2
3use std::collections::BTreeMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::error::{Error, Result};
12
13/// Metadata about a cached artifact.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ArtifactMetadata {
16    /// Package name.
17    pub package_name: String,
18    /// Task name.
19    pub task_name: String,
20    /// Command that produced this artifact.
21    pub command: String,
22    /// Cache key hash used to store this artifact.
23    pub cache_key_hash: String,
24    /// Timestamp when artifact was created (Unix epoch seconds).
25    pub created_at: u64,
26    /// Artifact format version.
27    pub version: u32,
28}
29
30/// Manifest of files contained in the artifact.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ArtifactManifest {
33    /// Map of relative paths to SHA-256 hashes.
34    pub files: BTreeMap<PathBuf, String>,
35    /// Total size of all files (uncompressed).
36    pub total_size: u64,
37}
38
39/// Cached task artifact containing outputs and metadata.
40///
41/// Artifacts are immutable once created and contain:
42/// - Metadata (task info, timestamps, cache key hash)
43/// - Manifest (list of output files with hashes)
44/// - Compressed output files
45#[derive(Debug)]
46pub struct Artifact {
47    metadata: ArtifactMetadata,
48    manifest: ArtifactManifest,
49    compressed_data: Vec<u8>,
50}
51
52impl Artifact {
53    /// Creates a new artifact from task outputs.
54    ///
55    /// # Arguments
56    ///
57    /// * `package_name` - Name of the package
58    /// * `task_name` - Name of the task
59    /// * `command` - Command that was executed
60    /// * `cache_key_hash` - Hash of the cache key
61    /// * `output_files` - Map of relative paths to file contents
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if compression or serialization fails.
66    pub fn new(
67        package_name: String,
68        task_name: String,
69        command: String,
70        cache_key_hash: String,
71        output_files: BTreeMap<PathBuf, Vec<u8>>,
72    ) -> Result<Self> {
73        let created_at = SystemTime::now()
74            .duration_since(UNIX_EPOCH)
75            .map_err(|e| Error::Adapter {
76                package: "artifact".to_string(),
77                message: format!("Failed to get timestamp: {}", e),
78            })?
79            .as_secs();
80
81        let mut files = BTreeMap::new();
82        let mut total_size = 0u64;
83
84        // Compute hashes for all files
85        for (path, content) in &output_files {
86            let mut hasher = Sha256::new();
87            hasher.update(content);
88            let hash = format!("{:x}", hasher.finalize());
89            files.insert(path.clone(), hash);
90            total_size += content.len() as u64;
91        }
92
93        let manifest = ArtifactManifest {
94            files,
95            total_size,
96        };
97
98        let metadata = ArtifactMetadata {
99            package_name,
100            task_name,
101            command,
102            cache_key_hash,
103            created_at,
104            version: 1,
105        };
106
107        // Create tar archive in memory
108        let mut tar_data = Vec::new();
109        {
110            let mut tar = tar::Builder::new(&mut tar_data);
111
112            // Add metadata.json
113            let metadata_json = serde_json::to_string(&metadata).map_err(|e| Error::Adapter {
114                package: "artifact".to_string(),
115                message: format!("Failed to serialize metadata: {}", e),
116            })?;
117            let mut metadata_header = tar::Header::new_gnu();
118            metadata_header.set_path("metadata.json").map_err(|e| Error::Adapter {
119                package: "artifact".to_string(),
120                message: format!("Failed to set metadata path: {}", e),
121            })?;
122            metadata_header.set_size(metadata_json.len() as u64);
123            metadata_header.set_cksum();
124            tar.append(&metadata_header, metadata_json.as_bytes())
125                .map_err(|e| Error::Adapter {
126                    package: "artifact".to_string(),
127                    message: format!("Failed to append metadata: {}", e),
128                })?;
129
130            // Add manifest.json
131            let manifest_json = serde_json::to_string(&manifest).map_err(|e| Error::Adapter {
132                package: "artifact".to_string(),
133                message: format!("Failed to serialize manifest: {}", e),
134            })?;
135            let mut manifest_header = tar::Header::new_gnu();
136            manifest_header.set_path("manifest.json").map_err(|e| Error::Adapter {
137                package: "artifact".to_string(),
138                message: format!("Failed to set manifest path: {}", e),
139            })?;
140            manifest_header.set_size(manifest_json.len() as u64);
141            manifest_header.set_cksum();
142            tar.append(&manifest_header, manifest_json.as_bytes())
143                .map_err(|e| Error::Adapter {
144                    package: "artifact".to_string(),
145                    message: format!("Failed to append manifest: {}", e),
146                })?;
147
148            // Add output files
149            for (path, content) in &output_files {
150                let mut header = tar::Header::new_gnu();
151                let output_path = Path::new("outputs").join(path);
152                header.set_path(&output_path).map_err(|e| Error::Adapter {
153                    package: "artifact".to_string(),
154                    message: format!("Failed to set output path: {}", e),
155                })?;
156                header.set_size(content.len() as u64);
157                header.set_cksum();
158                tar.append(&header, content.as_slice()).map_err(|e| Error::Adapter {
159                    package: "artifact".to_string(),
160                    message: format!("Failed to append output file: {}", e),
161                })?;
162            }
163
164            tar.finish().map_err(|e| Error::Adapter {
165                package: "artifact".to_string(),
166                message: format!("Failed to finish tar archive: {}", e),
167            })?;
168        }
169
170        // Compress with zstd
171        let compressed_data = zstd::encode_all(&tar_data[..], 3).map_err(|e| Error::Adapter {
172            package: "artifact".to_string(),
173            message: format!("Failed to compress artifact: {}", e),
174        })?;
175
176        Ok(Self {
177            metadata,
178            manifest,
179            compressed_data,
180        })
181    }
182
183    /// Reads an artifact from compressed data.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if decompression, deserialization, or verification fails.
188    pub fn from_compressed(data: Vec<u8>) -> Result<Self> {
189        // Decompress
190        let tar_data = zstd::decode_all(&data[..]).map_err(|e| Error::Adapter {
191            package: "artifact".to_string(),
192            message: format!("Failed to decompress artifact: {}", e),
193        })?;
194
195        // Extract from tar
196        let mut archive = tar::Archive::new(&tar_data[..]);
197        let mut metadata: Option<ArtifactMetadata> = None;
198        let mut manifest: Option<ArtifactManifest> = None;
199
200        for entry_result in archive.entries().map_err(|e| Error::Adapter {
201            package: "artifact".to_string(),
202            message: format!("Failed to read tar archive: {}", e),
203        })? {
204            let mut entry = entry_result.map_err(|e| Error::Adapter {
205                package: "artifact".to_string(),
206                message: format!("Failed to read tar entry: {}", e),
207            })?;
208
209            let path = entry.path().map_err(|e| Error::Adapter {
210                package: "artifact".to_string(),
211                message: format!("Failed to get entry path: {}", e),
212            })?;
213
214            if path == Path::new("metadata.json") {
215                let mut content = String::new();
216                entry.read_to_string(&mut content).map_err(|e| Error::Adapter {
217                    package: "artifact".to_string(),
218                    message: format!("Failed to read metadata: {}", e),
219                })?;
220                metadata = Some(serde_json::from_str(&content).map_err(|e| Error::Adapter {
221                    package: "artifact".to_string(),
222                    message: format!("Failed to parse metadata: {}", e),
223                })?);
224            } else if path == Path::new("manifest.json") {
225                let mut content = String::new();
226                entry.read_to_string(&mut content).map_err(|e| Error::Adapter {
227                    package: "artifact".to_string(),
228                    message: format!("Failed to read manifest: {}", e),
229                })?;
230                manifest = Some(serde_json::from_str(&content).map_err(|e| Error::Adapter {
231                    package: "artifact".to_string(),
232                    message: format!("Failed to parse manifest: {}", e),
233                })?);
234            }
235        }
236
237        let metadata = metadata.ok_or_else(|| Error::Adapter {
238            package: "artifact".to_string(),
239            message: "Missing metadata.json in artifact".to_string(),
240        })?;
241
242        let manifest = manifest.ok_or_else(|| Error::Adapter {
243            package: "artifact".to_string(),
244            message: "Missing manifest.json in artifact".to_string(),
245        })?;
246
247        Ok(Self {
248            metadata,
249            manifest,
250            compressed_data: data,
251        })
252    }
253
254    /// Returns the artifact metadata.
255    pub fn metadata(&self) -> &ArtifactMetadata {
256        &self.metadata
257    }
258
259    /// Returns the artifact manifest.
260    pub fn manifest(&self) -> &ArtifactManifest {
261        &self.manifest
262    }
263
264    /// Returns the compressed artifact data.
265    pub fn compressed_data(&self) -> &[u8] {
266        &self.compressed_data
267    }
268
269    /// Extracts output files from the artifact to the given directory.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if extraction fails.
274    pub fn extract_outputs(&self, output_dir: &Path) -> Result<()> {
275        use std::fs;
276
277        // Decompress
278        let tar_data = zstd::decode_all(&self.compressed_data[..]).map_err(|e| Error::Adapter {
279            package: "artifact".to_string(),
280            message: format!("Failed to decompress artifact: {}", e),
281        })?;
282
283        // Extract tar archive
284        let mut archive = tar::Archive::new(&tar_data[..]);
285        let outputs_dir = Path::new("outputs");
286
287        for entry_result in archive.entries().map_err(|e| Error::Adapter {
288            package: "artifact".to_string(),
289            message: format!("Failed to read tar archive: {}", e),
290        })? {
291            let mut entry = entry_result.map_err(|e| Error::Adapter {
292                package: "artifact".to_string(),
293                message: format!("Failed to read tar entry: {}", e),
294            })?;
295
296            let path = entry.path().map_err(|e| Error::Adapter {
297                package: "artifact".to_string(),
298                message: format!("Failed to get entry path: {}", e),
299            })?;
300
301            // Skip metadata and manifest
302            if path == Path::new("metadata.json") || path == Path::new("manifest.json") {
303                continue;
304            }
305
306            // Extract output files
307            if let Ok(relative_path) = path.strip_prefix(outputs_dir) {
308                let dest_path = output_dir.join(relative_path);
309                if let Some(parent) = dest_path.parent() {
310                    fs::create_dir_all(parent).map_err(Error::Io)?;
311                }
312                entry.unpack(&dest_path).map_err(|e| Error::Adapter {
313                    package: "artifact".to_string(),
314                    message: format!("Failed to extract file: {}", e),
315                })?;
316            }
317        }
318
319        Ok(())
320    }
321
322    /// Computes the SHA-256 hash of the compressed artifact.
323    pub fn hash(&self) -> String {
324        let mut hasher = Sha256::new();
325        hasher.update(&self.compressed_data);
326        format!("{:x}", hasher.finalize())
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_artifact_creation_and_extraction() {
336        let mut output_files = BTreeMap::new();
337        output_files.insert(PathBuf::from("file1.txt"), b"content1".to_vec());
338        output_files.insert(PathBuf::from("subdir/file2.txt"), b"content2".to_vec());
339
340        let artifact = Artifact::new(
341            "test-package".to_string(),
342            "build".to_string(),
343            "echo test".to_string(),
344            "abc123".to_string(),
345            output_files,
346        )
347        .unwrap();
348
349        assert_eq!(artifact.metadata().package_name, "test-package");
350        assert_eq!(artifact.metadata().task_name, "build");
351        assert_eq!(artifact.manifest().files.len(), 2);
352
353        // Test round-trip
354        let compressed = artifact.compressed_data().to_vec();
355        let artifact2 = Artifact::from_compressed(compressed).unwrap();
356
357        assert_eq!(artifact.metadata().package_name, artifact2.metadata().package_name);
358        assert_eq!(artifact.metadata().task_name, artifact2.metadata().task_name);
359        assert_eq!(artifact.manifest().files.len(), artifact2.manifest().files.len());
360    }
361}