Skip to main content

zoi_core/
recorder.rs

1use crate::types;
2use anyhow::{Result, anyhow};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{LazyLock, Mutex};
6
7static RECORD_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
8
9fn get_lockfile_path(scope: types::Scope) -> Result<PathBuf> {
10    let path = if scope == types::Scope::Project {
11        std::env::current_dir()?.join("zoi.lock")
12    } else {
13        let home_dir = crate::utils::get_user_home()
14            .ok_or_else(|| anyhow!("Could not find home directory."))?;
15        home_dir.join(".zoi").join("pkgs").join("zoi.lock")
16    };
17
18    if let Some(parent) = path.parent() {
19        fs::create_dir_all(parent)?;
20    }
21    Ok(path)
22}
23
24fn read_lockfile(scope: types::Scope) -> Result<types::ZoiLockV2> {
25    let path = get_lockfile_path(scope)?;
26    if !path.exists() || fs::read_to_string(&path)?.trim().is_empty() {
27        return Ok(types::ZoiLockV2 {
28            version: "2".to_string(),
29            ..Default::default()
30        });
31    }
32    let content = fs::read_to_string(path)?;
33    let lockfile = serde_json::from_str(&content)?;
34    Ok(lockfile)
35}
36
37/// Persists the state of the Zoi environment into the lockfile (`zoi.lock`).
38///
39/// Specification v2 uses a "Snapshot" model for reproducibility. Instead of just
40/// recording versions, Zoi computes:
41/// - `packages_hash`: A recursive SHA-512 hash of the entire package store.
42/// - `registries_hash`: A recursive SHA-512 hash of the metadata database.
43/// - Per-Package Hash: A hash of the specific version directory.
44///
45/// This ensures that a project environment can be verified for 100% bit-for-bit
46/// identicality across different machines.
47fn write_lockfile(lockfile: &mut types::ZoiLockV2, scope: types::Scope) -> Result<()> {
48    if crate::frozen::is_frozen() {
49        return Ok(());
50    }
51    let path = get_lockfile_path(scope)?;
52
53    if let Ok(store_dir) = crate::utils::get_store_base_dir(scope) {
54        lockfile.packages_hash = Some(format!(
55            "sha512-{}",
56            crate::hash::calculate_dir_hash(&store_dir).unwrap_or_default()
57        ));
58    }
59
60    let db_dir = if scope == types::Scope::Project {
61        std::env::current_dir()?
62            .join(".zoi")
63            .join("pkgs")
64            .join("db")
65    } else {
66        crate::utils::get_db_root().unwrap_or_default()
67    };
68
69    if db_dir.exists() {
70        lockfile.registries_hash = Some(format!(
71            "sha512-{}",
72            crate::hash::calculate_dir_hash(&db_dir).unwrap_or_default()
73        ));
74    }
75
76    let content = serde_json::to_string_pretty(lockfile)?;
77    fs::write(path, content)?;
78    Ok(())
79}
80
81pub fn record_package(
82    pkg: &types::Package,
83    reason: &types::InstallReason,
84    _installed_dependencies: &[String],
85    registry_handle: &str,
86    repo_type: &str,
87    _chosen_options: &[String],
88    _chosen_optionals: &[String],
89    sub_package: Option<String>,
90) -> Result<()> {
91    let _lock = RECORD_MUTEX
92        .lock()
93        .map_err(|e| anyhow!("Mutex poisoned: {}", e))?;
94    let mut lockfile = read_lockfile(pkg.scope)?;
95
96    let package_key = if let Some(sub) = &sub_package {
97        format!("@{}/{}:{}", pkg.repo.trim(), pkg.name.trim(), sub.trim())
98    } else {
99        format!("@{}/{}", pkg.repo.trim(), pkg.name.trim())
100    };
101
102    let os = std::env::consts::OS;
103    let arch = match std::env::consts::ARCH {
104        "x86_64" => "amd64",
105        "aarch64" => "arm64",
106        other => other,
107    };
108    let platform = format!("{}-{}", os, arch);
109
110    let hash = compute_package_hash(pkg, registry_handle);
111
112    let detail = types::LockPackageDetailV2 {
113        name: pkg.name.clone(),
114        sub_package: sub_package.clone(),
115        repo: pkg.repo.clone(),
116        repo_type: repo_type.to_string(),
117        version: pkg.version.clone().unwrap_or_default(),
118        epoch: pkg.epoch,
119        revision: pkg.revision.clone(),
120        registry: registry_handle.to_string(),
121        why: match reason {
122            types::InstallReason::Direct => "direct".to_string(),
123            types::InstallReason::Dependency { .. } => "dependency".to_string(),
124        },
125        description: pkg.description.clone(),
126        package_type_install: format!("{:?}", pkg.package_type).to_lowercase(),
127        install_method: if pkg.types.contains(&"source".to_string())
128            && !pkg.types.contains(&"pre-compiled".to_string())
129        {
130            "source".to_string()
131        } else {
132            "pre-compiled".to_string()
133        },
134        installed_sub_packages: sub_package.clone().map(|s| vec![s]).unwrap_or_default(),
135        platform,
136        hash,
137        dependencies: pkg.dependencies.clone().map(types::to_dependencies_v2),
138    };
139
140    lockfile.installed_packages.insert(package_key, detail);
141    lockfile.version = "2".to_string();
142
143    if !lockfile.registries.contains_key(registry_handle)
144        && let Some(reg_info) = resolve_registry_info(registry_handle)
145    {
146        lockfile
147            .registries
148            .insert(registry_handle.to_string(), reg_info);
149    }
150
151    write_lockfile(&mut lockfile, pkg.scope)
152}
153
154/// Calculates the current SHA-512 directory hash for an installed package version.
155///
156/// This is used to verify that the files in the store haven't been modified
157/// since they were originally staged.
158fn compute_package_hash(pkg: &types::Package, registry_handle: &str) -> String {
159    let Some(version) = &pkg.version else {
160        return String::new();
161    };
162    let Ok(store_base) = crate::utils::get_store_base_dir(pkg.scope) else {
163        return String::new();
164    };
165    let package_id = crate::utils::generate_package_id(registry_handle, &pkg.repo, &pkg.name);
166    let package_dir_name = crate::utils::get_package_dir_name(&package_id, &pkg.name);
167    let version_dir = store_base.join(&package_dir_name).join(version);
168    if version_dir.exists() {
169        format!(
170            "sha512-{}",
171            crate::hash::calculate_dir_hash(&version_dir).unwrap_or_default()
172        )
173    } else {
174        String::new()
175    }
176}
177
178fn resolve_registry_info(registry_handle: &str) -> Option<types::LockRegistryV2> {
179    let Ok(config) = crate::config::read_config() else {
180        return None;
181    };
182    let reg = config
183        .default_registry
184        .as_ref()
185        .filter(|r| r.handle == registry_handle)
186        .or_else(|| {
187            config
188                .added_registries
189                .iter()
190                .find(|r| r.handle == registry_handle)
191        })?;
192
193    let db_root = crate::utils::get_db_root().ok()?;
194    let reg_path = db_root.join(registry_handle);
195    let revision = resolve_git_head(&reg_path).unwrap_or_else(|| "unknown".to_string());
196
197    Some(types::LockRegistryV2 {
198        url: reg.url.clone(),
199        revision,
200    })
201}
202
203fn resolve_git_head(repo_path: &Path) -> Option<String> {
204    let head_file = repo_path.join(".git").join("HEAD");
205    let content = fs::read_to_string(&head_file).ok()?;
206    let content = content.trim();
207    if let Some(ref_path) = content.strip_prefix("ref: ") {
208        let ref_file = repo_path.join(".git").join(ref_path);
209        fs::read_to_string(&ref_file)
210            .ok()
211            .map(|s| s.trim().to_string())
212    } else {
213        Some(content.to_string())
214    }
215}
216
217pub fn update_package_reason(
218    manifest: &types::InstallManifest,
219    new_reason: types::InstallReason,
220) -> Result<()> {
221    let _lock = RECORD_MUTEX
222        .lock()
223        .map_err(|e| anyhow!("Mutex poisoned: {}", e))?;
224    let mut lockfile = read_lockfile(manifest.scope)?;
225    let repo = manifest.repo.trim();
226    let name = manifest.name.trim();
227
228    let package_key = if let Some(sub) = &manifest.sub_package {
229        format!("@{}/{}:{}", repo, name, sub.trim())
230    } else {
231        format!("@{}/{}", repo, name)
232    };
233
234    if let Some(pkg) = lockfile.installed_packages.get_mut(&package_key) {
235        pkg.why = match new_reason {
236            types::InstallReason::Direct => "direct".to_string(),
237            types::InstallReason::Dependency { .. } => "dependency".to_string(),
238        };
239        lockfile.version = "2".to_string();
240        write_lockfile(&mut lockfile, manifest.scope)?;
241        Ok(())
242    } else {
243        Err(anyhow!("Package '{}' not found in record.", manifest.name))
244    }
245}
246
247pub fn remove_package_from_record(manifest: &types::InstallManifest) -> Result<()> {
248    let _lock = RECORD_MUTEX
249        .lock()
250        .map_err(|e| anyhow!("Mutex poisoned: {}", e))?;
251    let mut lockfile = read_lockfile(manifest.scope)?;
252    let repo = manifest.repo.trim();
253    let name = manifest.name.trim();
254
255    let package_key = if let Some(sub) = &manifest.sub_package {
256        format!("@{}/{}:{}", repo, name, sub.trim())
257    } else {
258        format!("@{}/{}", repo, name)
259    };
260
261    if lockfile.installed_packages.remove(&package_key).is_some() {
262        lockfile.version = "2".to_string();
263        write_lockfile(&mut lockfile, manifest.scope)?;
264    }
265
266    Ok(())
267}
268
269pub fn get_recorded_packages() -> Result<Vec<types::LockPackageDetailV2>> {
270    let mut all_packages = Vec::new();
271    for scope in [
272        types::Scope::User,
273        types::Scope::System,
274        types::Scope::Project,
275    ] {
276        if let Ok(lockfile) = read_lockfile(scope) {
277            all_packages.extend(lockfile.installed_packages.into_values());
278        }
279    }
280    Ok(all_packages)
281}