Skip to main content

cfgd_core/modules/
lockfile.rs

1//! Module lockfile — tracking remote modules with integrity hashes,
2//! and module-spec diffing for sync output.
3
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::PathDisplayExt;
8use crate::config::{ModuleLockEntry, ModuleLockfile};
9use crate::errors::{ConfigError, ModuleError, Result};
10
11use super::LoadedModule;
12use super::git::{GitSource, fetch_git_source, git_cache_dir, parse_git_source, resolve_subdir};
13use super::loader::{load_module, load_modules};
14
15/// Load the module lockfile from `<config_dir>/modules.lock`.
16/// Returns an empty lockfile if the file does not exist.
17pub fn load_lockfile(config_dir: &Path) -> Result<ModuleLockfile> {
18    let lockfile_path = config_dir.join("modules.lock");
19    if !lockfile_path.exists() {
20        return Ok(ModuleLockfile::default());
21    }
22    let contents = std::fs::read_to_string(&lockfile_path).map_err(|e| ConfigError::Invalid {
23        message: format!("cannot read lockfile {}: {e}", lockfile_path.posix()),
24    })?;
25    let lockfile: ModuleLockfile = serde_yaml::from_str(&contents).map_err(ConfigError::from)?;
26    Ok(lockfile)
27}
28
29/// Save the module lockfile to `<config_dir>/modules.lock`.
30/// Uses `atomic_write_str` (temp file + rename) to prevent corruption.
31pub fn save_lockfile(config_dir: &Path, lockfile: &ModuleLockfile) -> Result<()> {
32    let lockfile_path = config_dir.join("modules.lock");
33    let contents = serde_yaml::to_string(lockfile).map_err(ConfigError::from)?;
34    crate::atomic_write_str(&lockfile_path, &contents).map_err(|e| ConfigError::Invalid {
35        message: format!("cannot write lockfile {}: {e}", lockfile_path.posix()),
36    })?;
37    Ok(())
38}
39
40/// Compute SHA-256 integrity hash of a module directory's contents.
41/// Hashes file paths (relative to module dir) and their contents, sorted for determinism.
42pub fn hash_module_contents(module_dir: &Path) -> Result<String> {
43    let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
44    collect_files_for_hash(module_dir, module_dir, &mut entries)?;
45    entries.sort_by(|a, b| a.0.cmp(&b.0));
46
47    let mut hasher_input = Vec::new();
48    for (rel_path, content) in &entries {
49        hasher_input.extend_from_slice(rel_path.as_bytes());
50        hasher_input.push(0);
51        hasher_input.extend_from_slice(content);
52        hasher_input.push(0);
53    }
54
55    Ok(crate::sha256_digest(&hasher_input))
56}
57
58fn collect_files_for_hash(
59    base: &Path,
60    current: &Path,
61    entries: &mut Vec<(String, Vec<u8>)>,
62) -> Result<()> {
63    if !current.is_dir() {
64        return Ok(());
65    }
66    let dir_entries = std::fs::read_dir(current)?;
67
68    for entry in dir_entries {
69        let entry = entry?;
70        let path = entry.path();
71        // Skip .git directory
72        if path.file_name().is_some_and(|n| n == ".git") {
73            continue;
74        }
75        // Skip symlinks — only hash real files to avoid infinite recursion
76        // and to avoid hashing files outside the module tree
77        let meta = std::fs::symlink_metadata(&path)?;
78        if meta.is_symlink() {
79            continue;
80        }
81        if meta.is_dir() {
82            collect_files_for_hash(base, &path, entries)?;
83        } else {
84            let rel = path
85                .strip_prefix(base)
86                .unwrap_or(&path)
87                .to_string_lossy()
88                .to_string();
89            let content = std::fs::read(&path)?;
90            entries.push((rel, content));
91        }
92    }
93    Ok(())
94}
95
96/// Verify the integrity of a locked remote module against its lockfile entry.
97pub fn verify_lockfile_integrity(lock_entry: &ModuleLockEntry, cache_base: &Path) -> Result<()> {
98    let git_src = parse_git_source(&lock_entry.url)?;
99    let local_path = resolve_subdir(
100        git_cache_dir(cache_base, &git_src.repo_url),
101        &lock_entry.subdir,
102        &lock_entry.name,
103        &lock_entry.url,
104    )?;
105
106    if !local_path.exists() {
107        return Err(ModuleError::GitFetchFailed {
108            module: lock_entry.name.clone(),
109            url: lock_entry.url.clone(),
110            message: "cached module directory does not exist — run 'cfgd module update'".into(),
111        }
112        .into());
113    }
114
115    let actual_integrity = hash_module_contents(&local_path)?;
116    if actual_integrity != lock_entry.integrity {
117        return Err(ModuleError::IntegrityMismatch {
118            name: lock_entry.name.clone(),
119            expected: lock_entry.integrity.clone(),
120            actual: actual_integrity,
121        }
122        .into());
123    }
124
125    Ok(())
126}
127
128/// Load remote modules from the lockfile, fetching if needed, and merge
129/// them into the given modules map.
130pub fn load_locked_modules(
131    config_dir: &Path,
132    cache_base: &Path,
133    modules: &mut HashMap<String, LoadedModule>,
134    printer: &crate::output::Printer,
135) -> Result<()> {
136    let lockfile = load_lockfile(config_dir)?;
137
138    for entry in &lockfile.modules {
139        // Skip if a local module with the same name already exists (local wins)
140        if modules.contains_key(&entry.name) {
141            continue;
142        }
143
144        let git_src = parse_git_source(&entry.url)?;
145
146        // Build a GitSource with the pinned ref
147        let pinned_src = GitSource {
148            repo_url: git_src.repo_url.clone(),
149            tag: Some(entry.pinned_ref.clone()),
150            git_ref: None,
151            subdir: entry.subdir.clone(),
152        };
153
154        // Fetch to cache (no-op if already present at correct ref)
155        let local_path = fetch_git_source(&pinned_src, cache_base, &entry.name, printer)?;
156
157        // Verify integrity
158        verify_lockfile_integrity(entry, cache_base)?;
159
160        // Load the module
161        let module = load_module(&local_path)?;
162        modules.insert(entry.name.clone(), module);
163    }
164
165    Ok(())
166}
167
168/// Load all modules: local modules from disk + remote locked modules.
169pub fn load_all_modules(
170    config_dir: &Path,
171    cache_base: &Path,
172    printer: &crate::output::Printer,
173) -> Result<HashMap<String, LoadedModule>> {
174    let mut modules = load_modules(config_dir)?;
175    load_locked_modules(config_dir, cache_base, &mut modules, printer)?;
176    Ok(modules)
177}
178
179/// Diff two module specs, returning a human-readable summary of changes.
180pub fn diff_module_specs(old: &LoadedModule, new: &LoadedModule) -> Vec<String> {
181    let mut changes = Vec::new();
182
183    // Dependencies
184    let old_deps: HashSet<&str> = old.spec.depends.iter().map(|s| s.as_str()).collect();
185    let new_deps: HashSet<&str> = new.spec.depends.iter().map(|s| s.as_str()).collect();
186    for dep in new_deps.difference(&old_deps) {
187        changes.push(format!("+ dependency: {dep}"));
188    }
189    for dep in old_deps.difference(&new_deps) {
190        changes.push(format!("- dependency: {dep}"));
191    }
192
193    // Packages
194    let old_pkgs: HashSet<&str> = old.spec.packages.iter().map(|p| p.name.as_str()).collect();
195    let new_pkgs: HashSet<&str> = new.spec.packages.iter().map(|p| p.name.as_str()).collect();
196    for pkg in new_pkgs.difference(&old_pkgs) {
197        changes.push(format!("+ package: {pkg}"));
198    }
199    for pkg in old_pkgs.difference(&new_pkgs) {
200        changes.push(format!("- package: {pkg}"));
201    }
202
203    // Check for version constraint changes on existing packages
204    for new_pkg in &new.spec.packages {
205        if let Some(old_pkg) = old.spec.packages.iter().find(|p| p.name == new_pkg.name)
206            && old_pkg.min_version != new_pkg.min_version
207        {
208            changes.push(format!(
209                "~ package '{}': minVersion {} -> {}",
210                new_pkg.name,
211                old_pkg.min_version.as_deref().unwrap_or("(none)"),
212                new_pkg.min_version.as_deref().unwrap_or("(none)")
213            ));
214        }
215    }
216
217    // Files
218    let old_files: HashSet<&str> = old.spec.files.iter().map(|f| f.target.as_str()).collect();
219    let new_files: HashSet<&str> = new.spec.files.iter().map(|f| f.target.as_str()).collect();
220    for file in new_files.difference(&old_files) {
221        changes.push(format!("+ file target: {file}"));
222    }
223    for file in old_files.difference(&new_files) {
224        changes.push(format!("- file target: {file}"));
225    }
226
227    // Scripts
228    let old_scripts: Vec<&str> = old
229        .spec
230        .scripts
231        .as_ref()
232        .map(|s| s.post_apply.iter().map(|e| e.run_str()).collect())
233        .unwrap_or_default();
234    let new_scripts: Vec<&str> = new
235        .spec
236        .scripts
237        .as_ref()
238        .map(|s| s.post_apply.iter().map(|e| e.run_str()).collect())
239        .unwrap_or_default();
240    let old_script_set: HashSet<&str> = old_scripts.into_iter().collect();
241    let new_script_set: HashSet<&str> = new_scripts.into_iter().collect();
242    for script in new_script_set.difference(&old_script_set) {
243        changes.push(format!("+ postApply script: {script}"));
244    }
245    for script in old_script_set.difference(&new_script_set) {
246        changes.push(format!("- postApply script: {script}"));
247    }
248
249    if changes.is_empty() {
250        changes.push("(no spec changes)".to_string());
251    }
252
253    changes
254}