cfgd_core/modules/
lockfile.rs1use 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
15pub 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
29pub 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
40pub 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 if path.file_name().is_some_and(|n| n == ".git") {
73 continue;
74 }
75 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
96pub 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
128pub 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 if modules.contains_key(&entry.name) {
141 continue;
142 }
143
144 let git_src = parse_git_source(&entry.url)?;
145
146 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 let local_path = fetch_git_source(&pinned_src, cache_base, &entry.name, printer)?;
156
157 verify_lockfile_integrity(entry, cache_base)?;
159
160 let module = load_module(&local_path)?;
162 modules.insert(entry.name.clone(), module);
163 }
164
165 Ok(())
166}
167
168pub 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
179pub fn diff_module_specs(old: &LoadedModule, new: &LoadedModule) -> Vec<String> {
181 let mut changes = Vec::new();
182
183 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 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 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 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 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}