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}