agpm_cli/lockfile/checksum.rs
1//! Checksum computation and verification for lockfile integrity.
2//!
3//! This module provides SHA-256 checksum operations for verifying file integrity,
4//! detecting corruption, and ensuring reproducible installations.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9
10use super::{LockFile, ResourceId};
11
12impl LockFile {
13 /// Compute SHA-256 checksum for file integrity verification.
14 ///
15 /// Detects corruption, tampering, or changes after installation.
16 ///
17 /// # Arguments
18 ///
19 /// * `path` - Path to the file to checksum
20 ///
21 /// # Returns
22 ///
23 /// * `Ok(String)` - Checksum in format "`sha256:hexadecimal_hash`"
24 /// * `Err(anyhow::Error)` - File read error with detailed context
25 ///
26 /// # Checksum Format
27 ///
28 /// The returned checksum follows the format:
29 /// - **Algorithm prefix**: "sha256:"
30 /// - **Hash encoding**: Lowercase hexadecimal
31 /// - **Length**: 71 characters total (7 for prefix + 64 hex digits)
32 ///
33 /// # Examples
34 ///
35 /// ```rust,no_run
36 /// use std::path::Path;
37 /// use agpm_cli::lockfile::LockFile;
38 ///
39 /// # fn example() -> anyhow::Result<()> {
40 /// let checksum = LockFile::compute_checksum(Path::new("example.md"))?;
41 /// println!("File checksum: {}", checksum);
42 /// // Output: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
43 /// # Ok(())
44 /// # }
45 /// ```
46 ///
47 /// # Error Handling
48 ///
49 /// Provides detailed error context for common issues:
50 /// - **File not found**: Suggests checking the path
51 /// - **Permission denied**: Suggests checking file permissions
52 /// - **IO errors**: Suggests checking disk health or file locks
53 ///
54 /// # Security Considerations
55 ///
56 /// - Uses SHA-256, a cryptographically secure hash function
57 /// - Suitable for integrity verification and tamper detection
58 /// - Consistent across platforms (Windows, macOS, Linux)
59 /// - Not affected by line ending differences (hashes actual bytes)
60 ///
61 /// # Performance
62 ///
63 /// The method reads the entire file into memory before hashing.
64 /// For very large files (>100MB), consider streaming implementations
65 /// in future versions.
66 pub fn compute_checksum(path: &Path) -> Result<String> {
67 use sha2::{Digest, Sha256};
68
69 let content = fs::read(path).with_context(|| {
70 format!(
71 "Cannot read file for checksum calculation: {}\n\n\
72 This error occurs when verifying file integrity.\n\
73 Check that the file exists and is readable.",
74 path.display()
75 )
76 })?;
77
78 let mut hasher = Sha256::new();
79 hasher.update(&content);
80 let result = hasher.finalize();
81
82 Ok(format!("sha256:{}", hex::encode(result)))
83 }
84
85 /// Verify file matches expected checksum.
86 ///
87 /// Computes current checksum and compares with expected value.
88 ///
89 /// # Arguments
90 ///
91 /// * `path` - Path to the file to verify
92 /// * `expected` - Expected checksum in "sha256:hex" format
93 ///
94 /// # Returns
95 ///
96 /// * `Ok(true)` - File checksum matches expected value
97 /// * `Ok(false)` - File checksum does not match (corruption detected)
98 /// * `Err(anyhow::Error)` - File read error or checksum calculation failed
99 ///
100 /// # Examples
101 ///
102 /// ```rust,no_run
103 /// use std::path::Path;
104 /// use agpm_cli::lockfile::LockFile;
105 ///
106 /// # fn example() -> anyhow::Result<()> {
107 /// let expected = "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3";
108 /// let is_valid = LockFile::verify_checksum(Path::new("example.md"), expected)?;
109 ///
110 /// if is_valid {
111 /// println!("File integrity verified");
112 /// } else {
113 /// println!("WARNING: File has been modified or corrupted!");
114 /// }
115 /// # Ok(())
116 /// # }
117 /// ```
118 ///
119 /// # Use Cases
120 ///
121 /// - **Installation verification**: Ensure copied files are intact
122 /// - **Periodic validation**: Detect file corruption over time
123 /// - **Security checks**: Detect unauthorized modifications
124 /// - **Troubleshooting**: Diagnose installation issues
125 ///
126 /// # Performance
127 ///
128 /// This method internally calls [`compute_checksum`](Self::compute_checksum),
129 /// so it has the same performance characteristics. For bulk verification
130 /// operations, consider caching computed checksums.
131 ///
132 /// # Security
133 ///
134 /// The comparison is performed using standard string equality, which is
135 /// not timing-attack resistant. Since checksums are not secrets, this
136 /// is acceptable for integrity verification purposes.
137 pub fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
138 let actual = Self::compute_checksum(path)?;
139 Ok(actual == expected)
140 }
141
142 /// Update checksum for resource identified by ResourceId.
143 ///
144 /// Used after installation to record actual file checksum. ResourceId ensures unique
145 /// identification via name, source, tool, and template_vars.
146 ///
147 /// # Arguments
148 ///
149 /// * `id` - The unique identifier for the resource
150 /// * `checksum` - The new SHA-256 checksum in "sha256:hex" format
151 ///
152 /// # Returns
153 ///
154 /// Returns `true` if the resource was found and updated, `false` otherwise.
155 ///
156 /// # Examples
157 ///
158 /// ```rust,no_run
159 /// # use agpm_cli::lockfile::{LockFile, LockedResourceBuilder, ResourceId};
160 /// # use agpm_cli::core::ResourceType;
161 /// # use agpm_cli::utils::compute_variant_inputs_hash;
162 /// # let mut lockfile = LockFile::default();
163 /// # // First add a resource to update
164 /// # let resource = LockedResourceBuilder::new(
165 /// # "my-agent".to_string(),
166 /// # "my-agent.md".to_string(),
167 /// # "".to_string(),
168 /// # "agents/my-agent.md".to_string(),
169 /// # ResourceType::Agent,
170 /// # )
171 /// # .tool(Some("claude-code".to_string()))
172 /// # .build();
173 /// # lockfile.add_typed_resource("my-agent".to_string(), resource, ResourceType::Agent);
174 /// let variant_hash = compute_variant_inputs_hash(&serde_json::json!({})).unwrap_or_default();
175 /// let id = ResourceId::new("my-agent", None::<String>, Some("claude-code"), ResourceType::Agent, variant_hash);
176 /// let updated = lockfile.update_resource_checksum(&id, "sha256:abcdef123456...");
177 /// assert!(updated);
178 /// ```
179 pub fn update_resource_checksum(&mut self, id: &ResourceId, checksum: &str) -> bool {
180 // Try each resource type until we find a match by comparing ResourceIds
181 for resource in &mut self.agents {
182 if resource.id() == *id {
183 resource.checksum = checksum.to_string();
184 return true;
185 }
186 }
187
188 for resource in &mut self.snippets {
189 if resource.id() == *id {
190 resource.checksum = checksum.to_string();
191 return true;
192 }
193 }
194
195 for resource in &mut self.commands {
196 if resource.id() == *id {
197 resource.checksum = checksum.to_string();
198 return true;
199 }
200 }
201
202 for resource in &mut self.scripts {
203 if resource.id() == *id {
204 resource.checksum = checksum.to_string();
205 return true;
206 }
207 }
208
209 for resource in &mut self.hooks {
210 if resource.id() == *id {
211 resource.checksum = checksum.to_string();
212 return true;
213 }
214 }
215
216 for resource in &mut self.mcp_servers {
217 if resource.id() == *id {
218 resource.checksum = checksum.to_string();
219 return true;
220 }
221 }
222
223 false
224 }
225
226 /// Update context checksum for resource by ResourceId.
227 ///
228 /// Stores the SHA-256 checksum of template rendering inputs (context) in the lockfile.
229 /// This is different from the file checksum which covers the final rendered content.
230 ///
231 /// # Arguments
232 ///
233 /// * `id` - The ResourceId identifying the resource to update
234 /// * `context_checksum` - The SHA-256 checksum of template context, or None for non-templated resources
235 ///
236 /// # Returns
237 ///
238 /// Returns `true` if the resource was found and updated, `false` otherwise.
239 ///
240 /// # Examples
241 ///
242 /// ```rust,ignore
243 /// let mut lockfile = LockFile::new();
244 /// let id = ResourceId::new("my-agent", None::<String>, Some("claude-code"), ResourceType::Agent, serde_json::json!({}));
245 /// let updated = lockfile.update_resource_context_checksum(&id, Some("sha256:context123456..."));
246 /// assert!(updated);
247 /// ```
248 pub fn update_resource_context_checksum(
249 &mut self,
250 id: &ResourceId,
251 context_checksum: &str,
252 ) -> bool {
253 // Try each resource type until we find a match by comparing ResourceIds
254 for resource in &mut self.agents {
255 if resource.id() == *id {
256 resource.context_checksum = Some(context_checksum.to_string());
257 return true;
258 }
259 }
260
261 for resource in &mut self.snippets {
262 if resource.id() == *id {
263 resource.context_checksum = Some(context_checksum.to_string());
264 return true;
265 }
266 }
267
268 for resource in &mut self.commands {
269 if resource.id() == *id {
270 resource.context_checksum = Some(context_checksum.to_string());
271 return true;
272 }
273 }
274
275 for resource in &mut self.scripts {
276 if resource.id() == *id {
277 resource.context_checksum = Some(context_checksum.to_string());
278 return true;
279 }
280 }
281
282 for resource in &mut self.hooks {
283 if resource.id() == *id {
284 resource.context_checksum = Some(context_checksum.to_string());
285 return true;
286 }
287 }
288
289 for resource in &mut self.mcp_servers {
290 if resource.id() == *id {
291 resource.context_checksum = Some(context_checksum.to_string());
292 return true;
293 }
294 }
295
296 false
297 }
298
299 /// Update applied patches for resource by name.
300 ///
301 /// Stores project patches in main lockfile; private patches go to agpm.private.lock.
302 /// Takes `AppliedPatches` from installer.
303 ///
304 /// # Arguments
305 ///
306 /// * `name` - The name of the resource to update
307 /// * `applied_patches` - The patches that were applied (from `AppliedPatches` struct)
308 ///
309 /// # Returns
310 ///
311 /// Returns `true` if the resource was found and updated, `false` otherwise.
312 ///
313 /// # Examples
314 ///
315 /// ```no_run
316 /// # use agpm_cli::lockfile::LockFile;
317 /// # use agpm_cli::manifest::patches::AppliedPatches;
318 /// # use std::collections::HashMap;
319 /// # let mut lockfile = LockFile::new();
320 /// let mut applied = AppliedPatches::new();
321 /// applied.project.insert("model".to_string(), toml::Value::String("haiku".into()));
322 ///
323 /// let updated = lockfile.update_resource_applied_patches("my-agent", &applied);
324 /// assert!(updated);
325 /// ```
326 pub fn update_resource_applied_patches(
327 &mut self,
328 name: &str,
329 applied_patches: &crate::manifest::patches::AppliedPatches,
330 ) -> bool {
331 // Store ONLY project patches in the main lockfile (agpm.lock)
332 // Private patches are stored separately in agpm.private.lock
333 // This ensures the main lockfile is deterministic and safe to commit
334 let project_patches = applied_patches.project.clone();
335
336 // Try each resource type until we find a match
337 for resource in &mut self.agents {
338 if resource.name == name {
339 resource.applied_patches = project_patches;
340 return true;
341 }
342 }
343
344 for resource in &mut self.snippets {
345 if resource.name == name {
346 resource.applied_patches = project_patches;
347 return true;
348 }
349 }
350
351 for resource in &mut self.commands {
352 if resource.name == name {
353 resource.applied_patches = project_patches;
354 return true;
355 }
356 }
357
358 for resource in &mut self.scripts {
359 if resource.name == name {
360 resource.applied_patches = project_patches;
361 return true;
362 }
363 }
364
365 for resource in &mut self.hooks {
366 if resource.name == name {
367 resource.applied_patches = project_patches;
368 return true;
369 }
370 }
371
372 for resource in &mut self.mcp_servers {
373 if resource.name == name {
374 resource.applied_patches = project_patches;
375 return true;
376 }
377 }
378
379 false
380 }
381
382 /// Apply installation results to the lockfile in batch.
383 ///
384 /// Updates the lockfile with checksums, context checksums, and applied patches
385 /// from the installation process. This consolidates three separate update operations
386 /// into one batch call, reducing code duplication between install and update commands.
387 ///
388 /// # Batch Processing Pattern
389 ///
390 /// This function processes three parallel vectors of installation results:
391 /// 1. **File checksums** - SHA-256 of rendered content (triggers reinstall if changed)
392 /// 2. **Context checksums** - SHA-256 of template inputs (audit/debug only)
393 /// 3. **Applied patches** - Tracks which project patches were applied to each resource
394 ///
395 /// The batch approach ensures all three updates are applied consistently and
396 /// atomically to the lockfile, avoiding partial state.
397 ///
398 /// # Arguments
399 ///
400 /// * `checksums` - File checksums for each installed resource (by ResourceId)
401 /// * `context_checksums` - Context checksums for template inputs (Optional)
402 /// * `applied_patches_list` - Patches that were applied to each resource
403 ///
404 /// # Implementation Details
405 ///
406 /// - Updates are applied by ResourceId to handle duplicate resource names correctly
407 /// - Context checksums are only applied if present (non-templated resources have None)
408 /// - Only project patches are stored; private patches go to `agpm.private.lock`
409 /// - Called by both `install` and `update` commands after parallel installation
410 ///
411 /// # Examples
412 ///
413 /// ```rust,no_run
414 /// # use agpm_cli::lockfile::{LockFile, ResourceId};
415 /// # use agpm_cli::manifest::patches::AppliedPatches;
416 /// # use agpm_cli::core::ResourceType;
417 /// let mut lockfile = LockFile::default();
418 ///
419 /// // Collect results from parallel installation
420 /// let checksums = vec![/* (ResourceId, checksum) pairs */];
421 /// let context_checksums = vec![/* (ResourceId, Option<checksum>) pairs */];
422 /// let applied_patches = vec![/* (ResourceId, AppliedPatches) pairs */];
423 ///
424 /// // Apply all results in batch (replaces 3 separate loops)
425 /// lockfile.apply_installation_results(
426 /// checksums,
427 /// context_checksums,
428 /// applied_patches,
429 /// );
430 /// ```
431 ///
432 pub fn apply_installation_results(
433 &mut self,
434 checksums: Vec<(ResourceId, String)>,
435 context_checksums: Vec<(ResourceId, Option<String>)>,
436 applied_patches_list: Vec<(ResourceId, crate::manifest::patches::AppliedPatches)>,
437 ) {
438 // Update lockfile with checksums
439 for (id, checksum) in checksums {
440 self.update_resource_checksum(&id, &checksum);
441 }
442
443 // Update lockfile with context checksums
444 for (id, context_checksum) in context_checksums {
445 if let Some(checksum) = context_checksum {
446 self.update_resource_context_checksum(&id, &checksum);
447 }
448 }
449
450 // Update lockfile with applied patches
451 for (id, applied_patches) in applied_patches_list {
452 self.update_resource_applied_patches(id.name(), &applied_patches);
453 }
454 }
455}