Skip to main content

sdkt_core/
lock.rs

1//! Project lock-file generation for the Soroban DevKit (sdkt).
2//!
3//! M34.1 — `sdkt.lock` generation.
4//!
5//! After `sdkt build` produces the workspace WASM artifacts, a `sdkt.lock`
6//! file is written next to `.sdkt.toml`. It records, for every contract in the
7//! resolved deployment graph, the authoritative artifact path, its SHA-256
8//! hash, and the deterministic deploy order produced by
9//! [`crate::project::resolve_deploy_order`].
10//!
11//! The lock is **advisory**: `sdkt project deploy` still works without it, and
12//! a stale/mismatched lock only produces a warning (never a hard failure), so
13//! existing workflows and CI that build fresh artifacts remain unaffected.
14
15use crate::config::DevKitConfig;
16use crate::project::{resolve_deploy_order, ProjectError};
17use serde::{Deserialize, Serialize};
18use std::fmt;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22/// One locked contract entry.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct LockEntry {
25    /// Contract alias (matches `[contracts.<alias>]` in `.sdkt.toml`).
26    pub alias: String,
27    /// Source directory path (relative to the workspace root / `.sdkt.toml`).
28    pub path: String,
29    /// Resolved WASM artifact path (relative to the workspace root).
30    pub artifact: String,
31    /// Lowercase hex SHA-256 of the artifact bytes.
32    pub sha256: String,
33    /// Position in the deterministic deploy order (0 = first to deploy).
34    pub order: usize,
35}
36
37/// One locked dependency entry (M35.0 / M35.1 / M35.2).
38///
39/// Mirrors a resolved `[dependencies.*]` entry from `.sdkt.toml`. Local path
40/// deps record nothing but their source kind and absolute-ish path; Git deps
41/// record the URL, the requested reference, the resolved commit SHA (when
42/// known), the on-disk cache location, and an integrity hash of the cached
43/// checkout when available. This is the seam where a future registry source
44/// would add its own fields.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
46pub struct DependencyLock {
47    /// Dependency name (key under `[dependencies]`).
48    pub name: String,
49    /// Source kind: `local` or `git`.
50    pub source: String,
51    /// Original source specifier as declared in `.sdkt.toml`:
52    /// the local path (path deps) or the git URL (git deps).
53    #[serde(default, skip_serializing_if = "String::is_empty")]
54    pub original_source: String,
55    /// Git remote URL (empty for local path deps).
56    #[serde(default, skip_serializing_if = "String::is_empty")]
57    pub git_url: String,
58    /// Requested reference: `tag`/`branch`/`rev` value (empty for path deps).
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub resolved_reference: String,
61    /// Resolved commit SHA (empty if not available / not a Git dep).
62    #[serde(default, skip_serializing_if = "String::is_empty")]
63    pub commit_sha: String,
64    /// On-disk cache location for the resolved source (relative to the
65    /// workspace root when sensible). Empty when not materialized.
66    #[serde(default, skip_serializing_if = "String::is_empty")]
67    pub cache_location: String,
68    /// Integrity hash ("sha256:<hex>") of the cached checkout's tracked tree,
69    /// when computed. Empty when not available (e.g. un-fetched path dep).
70    #[serde(default, skip_serializing_if = "String::is_empty")]
71    pub integrity: String,
72    /// Optional semver version constraint declared for this git dependency
73    /// (M37). Empty when none. Recorded for reproducibility / audit; an explicit
74    /// `tag`/`branch`/`rev` reference takes precedence over the constraint.
75    #[serde(default, skip_serializing_if = "String::is_empty")]
76    pub version: String,
77}
78
79/// The on-disk `sdkt.lock` structure.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct LockFile {
82    /// Schema version. Bump if the layout changes incompatibly.
83    pub version: u32,
84    /// Deterministic deploy order (alias list, index 0 deploys first).
85    pub deploy_order: Vec<String>,
86    /// Per-contract locked artifacts.
87    pub contracts: Vec<LockEntry>,
88    /// Locked package dependencies (M35.0 / M35.1). Absent in older locks.
89    #[serde(default)]
90    pub dependencies: Vec<DependencyLock>,
91}
92
93/// Errors raised while generating or verifying the lock file.
94#[derive(Debug)]
95pub enum LockError {
96    /// Underlying project resolution failure (e.g. unknown dependency).
97    Project(ProjectError),
98    /// A contract's WASM artifact could not be located.
99    ArtifactNotFound(String),
100    /// Reading/hashing an artifact failed.
101    Io {
102        alias: String,
103        source: std::io::Error,
104    },
105    /// Serializing or writing the lock file failed.
106    Write {
107        path: PathBuf,
108        source: Box<dyn std::error::Error>,
109    },
110    /// Parsing an existing lock file failed.
111    Parse {
112        path: PathBuf,
113        source: Box<dyn std::error::Error>,
114    },
115}
116
117impl fmt::Display for LockError {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            LockError::Project(e) => write!(f, "project resolution failed: {}", e),
121            LockError::ArtifactNotFound(alias) => {
122                write!(f, "no WASM artifact found for contract '{}'", alias)
123            }
124            LockError::Io { alias, source } => {
125                write!(f, "I/O error reading artifact for '{}': {}", alias, source)
126            }
127            LockError::Write { path, source } => {
128                write!(
129                    f,
130                    "failed to write lock file {}: {}",
131                    path.display(),
132                    source
133                )
134            }
135            LockError::Parse { path, source } => {
136                write!(
137                    f,
138                    "failed to parse lock file {}: {}",
139                    path.display(),
140                    source
141                )
142            }
143        }
144    }
145}
146
147impl std::error::Error for LockError {}
148
149impl From<ProjectError> for LockError {
150    fn from(e: ProjectError) -> Self {
151        LockError::Project(e)
152    }
153}
154
155/// Current `sdkt.lock` schema version.
156pub const LOCK_VERSION: u32 = 1;
157
158/// Default lock file name, written next to `.sdkt.toml`.
159pub const LOCK_FILE_NAME: &str = "sdkt.lock";
160
161/// Compute the lowercase-hex SHA-256 of a file's bytes.
162pub fn compute_sha256(path: &Path) -> Result<String, LockError> {
163    use sha2::Digest;
164    let bytes = fs::read(path).map_err(|source| LockError::Io {
165        alias: path.display().to_string(),
166        source,
167    })?;
168    let mut hasher = sha2::Sha256::new();
169    hasher.update(&bytes);
170    let digest = hasher.finalize();
171    Ok(format!("{:x}", digest))
172}
173
174/// Locate the single `*.wasm` artifact for a contract, matching the same logic
175/// `resolve_project` uses (first `.wasm` under the release target dir). Paths in
176/// `contract_path` are resolved relative to `base_dir`.
177fn find_artifact(base_dir: &Path, contract_path: &str) -> Option<PathBuf> {
178    let base = base_dir.join(contract_path);
179    let target_dir = base
180        .join("target")
181        .join("wasm32-unknown-unknown")
182        .join("release");
183    if !target_dir.exists() {
184        return None;
185    }
186    let entries = fs::read_dir(&target_dir).ok()?;
187    for entry in entries.flatten() {
188        let p = entry.path();
189        if p.extension().is_some_and(|ext| ext == "wasm") {
190            return Some(p);
191        }
192    }
193    None
194}
195
196/// Generate a [`LockFile`] from the resolved project graph.
197///
198/// `base_dir` is the workspace root that contract `path` values in `.sdkt.toml`
199/// are resolved against (in production this is `.`, the cwd sdkt runs from).
200///
201/// Pure-ish: resolves the deploy order from `config`, then for each contract
202/// locates its WASM artifact and hashes it. Returns [`LockError::ArtifactNotFound`]
203/// if any artifact is missing (e.g. `sdkt build` was not run first).
204pub fn generate_lock(base_dir: &Path, config: &DevKitConfig) -> Result<LockFile, LockError> {
205    let ordered = resolve_deploy_order(config)?;
206
207    let mut contracts = Vec::with_capacity(ordered.len());
208    for (order, alias) in ordered.iter().enumerate() {
209        let cfg = config
210            .contracts
211            .get(alias)
212            .expect("alias came from config.contracts");
213        let artifact = find_artifact(base_dir, &cfg.path)
214            .ok_or_else(|| LockError::ArtifactNotFound(alias.clone()))?;
215        let sha256 = compute_sha256(&artifact)?;
216        // Persist the artifact path as given in the config (relative form is
217        // what `.sdkt.toml` uses and what users expect in the lock).
218        let artifact_str = artifact.to_string_lossy().to_string();
219        contracts.push(LockEntry {
220            alias: alias.clone(),
221            path: cfg.path.clone(),
222            artifact: artifact_str,
223            sha256,
224            order,
225        });
226    }
227
228    Ok(LockFile {
229        version: LOCK_VERSION,
230        deploy_order: ordered,
231        contracts,
232        dependencies: lock_dependencies(base_dir, &config.dependencies),
233    })
234}
235
236/// Build [`DependencyLock`] entries from a `.sdkt.toml` dependency map.
237///
238/// Pure: does not fetch. For Git deps it records the URL + requested
239/// reference; `commit_sha`/`cache_location`/`integrity` are filled in by the
240/// caller after a fetch (or left empty when only locking the manifest). Local
241/// path deps record their source path as `original_source`. This keeps
242/// `sdkt.lock` stable across machines that haven't fetched yet.
243///
244/// `base_dir` is the workspace root used to resolve local paths so they can be
245/// recorded (and later verified) as absolute-ish paths.
246pub fn lock_dependencies(
247    base_dir: &Path,
248    deps: &std::collections::HashMap<String, crate::config::Dependency>,
249) -> Vec<DependencyLock> {
250    let mut out = Vec::with_capacity(deps.len());
251    for (name, dep) in deps {
252        if dep.git.is_some() {
253            let reference = dep
254                .tag
255                .clone()
256                .or_else(|| dep.branch.clone())
257                .or_else(|| dep.rev.clone())
258                .unwrap_or_default();
259            out.push(DependencyLock {
260                name: name.clone(),
261                source: "git".to_string(),
262                original_source: dep.git.clone().unwrap_or_default(),
263                git_url: dep.git.clone().unwrap_or_default(),
264                resolved_reference: reference,
265                commit_sha: String::new(),
266                cache_location: String::new(),
267                integrity: String::new(),
268                version: dep.version.clone().unwrap_or_default(),
269            });
270        } else {
271            let path = dep.path.clone().unwrap_or_default();
272            // Record the local path resolved against the workspace root so
273            // verification can check existence without assuming the cwd.
274            let resolved = if path.is_empty() {
275                path.clone()
276            } else {
277                base_dir.join(&path).to_string_lossy().to_string()
278            };
279            out.push(DependencyLock {
280                name: name.clone(),
281                source: "local".to_string(),
282                original_source: resolved,
283                git_url: String::new(),
284                resolved_reference: String::new(),
285                commit_sha: String::new(),
286                cache_location: String::new(),
287                integrity: String::new(),
288                version: String::new(),
289            });
290        }
291    }
292    out
293}
294
295/// Rebuild [`DependencyLock`] entries from a set of already-resolved
296/// [`FetchOutcome`]s, recording the resolved commit, cache location, and an
297/// offline integrity hash for each.
298///
299/// This is the single source of truth used by both `sdkt package fetch` and
300/// `sdkt package update` when writing dependency entries into `sdkt.lock`, so
301/// the two never drift. Local `path` deps in `config` that have no
302/// corresponding outcome keep their manifest-derived `original_source` (their
303/// `commit_sha`/`cache_location`/`integrity` stay empty). Git deps take the
304/// commit SHA and on-disk path from the matching outcome.
305pub fn lock_dependencies_resolved(
306    base_dir: &Path,
307    config: &DevKitConfig,
308    fetched: &[crate::fetch::FetchOutcome],
309) -> Vec<DependencyLock> {
310    let mut out = Vec::with_capacity(config.dependencies.len());
311    for (name, dep) in &config.dependencies {
312        if let Some(outcome) = fetched.iter().find(|o| o.name == *name) {
313            let (source, original_source, git_url, resolved_reference) = if dep.git.is_some() {
314                (
315                    "git".to_string(),
316                    dep.git.clone().unwrap_or_default(),
317                    dep.git.clone().unwrap_or_default(),
318                    dep.tag
319                        .clone()
320                        .or_else(|| dep.branch.clone())
321                        .or_else(|| dep.rev.clone())
322                        .unwrap_or_default(),
323                )
324            } else {
325                let resolved = base_dir
326                    .join(dep.path.clone().unwrap_or_default())
327                    .to_string_lossy()
328                    .to_string();
329                ("local".to_string(), resolved, String::new(), String::new())
330            };
331            let integrity = compute_dependency_integrity(base_dir, dep);
332            out.push(DependencyLock {
333                name: name.clone(),
334                source,
335                original_source,
336                git_url,
337                resolved_reference,
338                commit_sha: outcome.resolved_rev.clone(),
339                cache_location: outcome.local_path.display().to_string(),
340                integrity,
341                version: dep.version.clone().unwrap_or_default(),
342            });
343        } else {
344            // No fetched outcome (e.g. a local path dep, or an unchanged git dep
345            // whose lock entry is preserved separately). Fall back to the pure
346            // manifest-derived record so the entry still exists in the lock.
347            out.push(
348                lock_dependencies(base_dir, &config.dependencies)
349                    .into_iter()
350                    .find(|d| d.name == *name)
351                    .unwrap_or_else(|| DependencyLock {
352                        name: name.clone(),
353                        ..Default::default()
354                    }),
355            );
356        }
357    }
358    out
359}
360
361/// Read a [`LockFile`] and return its locked dependencies (empty if none).
362pub fn locked_dependencies(lock: &LockFile) -> &[DependencyLock] {
363    &lock.dependencies
364}
365pub fn lock_to_toml(lock: &LockFile) -> Result<String, LockError> {
366    toml::to_string_pretty(lock).map_err(|source| LockError::Write {
367        path: PathBuf::from(LOCK_FILE_NAME),
368        source: Box::new(source),
369    })
370}
371
372/// Write a [`LockFile`] to `dir/sdkt.lock`.
373pub fn write_lock(dir: &Path, lock: &LockFile) -> Result<PathBuf, LockError> {
374    let toml = lock_to_toml(lock)?;
375    let path = dir.join(LOCK_FILE_NAME);
376    fs::write(&path, toml).map_err(|source| LockError::Write {
377        path: path.clone(),
378        source: Box::new(source),
379    })?;
380    Ok(path)
381}
382
383/// Parse a [`LockFile`] from a TOML string.
384pub fn lock_from_toml(content: &str) -> Result<LockFile, LockError> {
385    toml::from_str(content).map_err(|source| LockError::Parse {
386        path: PathBuf::from(LOCK_FILE_NAME),
387        source: Box::new(source),
388    })
389}
390
391/// Read and parse a [`LockFile`] from `dir/sdkt.lock`.
392pub fn read_lock(dir: &Path) -> Result<LockFile, LockError> {
393    let path = dir.join(LOCK_FILE_NAME);
394    let content = fs::read_to_string(&path).map_err(|source| LockError::Parse {
395        path: path.clone(),
396        source: Box::new(source),
397    })?;
398    lock_from_toml(&content)
399}
400
401/// Outcome of a lock verification against the live artifacts.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct LockVerifyReport {
404    /// Lock file was present and parsed successfully.
405    pub present: bool,
406    /// True when every locked artifact still matches its recorded hash.
407    pub consistent: bool,
408    /// Aliases whose hash differs from the lock (empty when consistent).
409    pub mismatched: Vec<String>,
410    /// Aliases in the config that are absent from the lock.
411    pub missing_in_lock: Vec<String>,
412}
413
414/// Verify an existing lock file against the current on-disk artifacts.
415///
416/// `base_dir` is the workspace root the lock file and artifact paths are
417/// resolved against (production: `.`, the cwd sdkt runs from).
418///
419/// This is **advisory**: it never errors on a stale lock, it simply reports
420/// which artifacts drifted. A missing lock yields `present = false` with
421/// `consistent = false` and `missing_in_lock` populated from the config.
422pub fn verify_lock(base_dir: &Path, config: &DevKitConfig) -> LockVerifyReport {
423    let Ok(lock) = read_lock(base_dir) else {
424        let missing_in_lock = config.contracts.keys().cloned().collect();
425        return LockVerifyReport {
426            present: false,
427            consistent: false,
428            mismatched: vec![],
429            missing_in_lock,
430        };
431    };
432
433    let mut mismatched = Vec::new();
434    let locked_aliases: std::collections::HashSet<String> =
435        lock.contracts.iter().map(|c| c.alias.clone()).collect();
436    let mut missing_in_lock = Vec::new();
437
438    for alias in config.contracts.keys() {
439        if !locked_aliases.contains(alias) {
440            missing_in_lock.push(alias.clone());
441        }
442    }
443
444    for entry in &lock.contracts {
445        let artifact = base_dir.join(&entry.artifact);
446        let Ok(current) = compute_sha256(&artifact) else {
447            // Artifact gone entirely — treat as mismatch.
448            mismatched.push(entry.alias.clone());
449            continue;
450        };
451        if current != entry.sha256 {
452            mismatched.push(entry.alias.clone());
453        }
454    }
455
456    let consistent = mismatched.is_empty() && missing_in_lock.is_empty();
457    LockVerifyReport {
458        present: true,
459        consistent,
460        mismatched,
461        missing_in_lock,
462    }
463}
464
465/// A single dependency-lock mismatch discovered during verification (M35.2).
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum DepMismatchKind {
468    /// Dependency in the manifest is absent from the lock.
469    MissingInLock,
470    /// Dependency in the lock is absent from the manifest.
471    NotInManifest,
472    /// Source kind (`path` vs `git`) or the source locator changed.
473    SourceChanged,
474    /// The requested `tag`/`branch`/`rev` reference changed.
475    ReferenceChanged,
476    /// A Git dependency's local cache checkout is missing.
477    CacheMissing,
478    /// A local path dependency no longer exists on disk.
479    PathMissing,
480    /// A Git cache checkout resolves to a different commit than the lock.
481    CommitMismatch,
482    /// Optional integrity hash differs from the current checkout.
483    IntegrityMismatch,
484}
485
486/// One dependency-lock drift record.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct DepMismatch {
489    /// Dependency name (key under `[dependencies]`).
490    pub name: String,
491    /// Which kind of drift was detected.
492    pub kind: DepMismatchKind,
493    /// Human-readable detail (paths, expected vs actual, etc.).
494    pub detail: String,
495}
496
497/// Outcome of verifying locked package dependencies against the live manifest
498/// and on-disk state (M35.2).
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct DepVerifyReport {
501    /// A lock file was present and parsed successfully.
502    pub present: bool,
503    /// True when every dependency matches the manifest and on-disk state.
504    pub consistent: bool,
505    /// How many dependencies were checked.
506    pub checked: usize,
507    /// Every drift record (empty when consistent).
508    pub mismatches: Vec<DepMismatch>,
509}
510
511/// Resolve the current HEAD commit SHA of a local git checkout (empty if the
512/// checkout is missing or git is unavailable). Reuses the fetcher's git
513/// discovery so it works in restricted PATH environments.
514fn git_head_commit(checkout: &Path) -> String {
515    let out = std::process::Command::new(crate::fetch::git_bin())
516        .current_dir(checkout)
517        .args(["rev-parse", "HEAD"])
518        .output();
519    match out {
520        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
521        _ => String::new(),
522    }
523}
524
525/// Compute a deterministic integrity string for a dependency's on-disk source.
526///
527/// * Git deps: `sha256:<tree-hash>` via `git rev-parse HEAD^{tree}` of the
528///   cached checkout (stable across machines for the same tree).
529/// * Local path deps: `sha256:<hash>` over the sorted relative file paths and
530///   their contents (so a byte change anywhere in the tree is detected).
531///
532/// Returns an empty string when the source cannot be read (e.g. not fetched
533/// yet). This is purely offline — no network, no registry.
534pub fn compute_dependency_integrity(base_dir: &Path, dep: &crate::config::Dependency) -> String {
535    if let Some(_git) = &dep.git {
536        let key = crate::fetch::git_cache_key(dep);
537        let checkout = base_dir.join(".sdkt-cache").join("git").join(&key);
538        if checkout.join(".git").exists() {
539            let out = std::process::Command::new(crate::fetch::git_bin())
540                .current_dir(&checkout)
541                .args(["rev-parse", "HEAD^{tree}"])
542                .output();
543            if let Ok(o) = out {
544                if o.status.success() {
545                    let tree = String::from_utf8_lossy(&o.stdout).trim().to_string();
546                    if !tree.is_empty() {
547                        return format!("sha256:{}", tree);
548                    }
549                }
550            }
551        }
552        return String::new();
553    }
554
555    // Local path dependency: hash the directory tree deterministically.
556    let path = dep.path.clone().unwrap_or_default();
557    if path.is_empty() {
558        return String::new();
559    }
560    let abs = base_dir.join(&path);
561    if !abs.is_dir() {
562        return String::new();
563    }
564    use sha2::Digest;
565    let mut hasher = sha2::Sha256::new();
566    let mut files = Vec::new();
567    // Recursive directory walk with only stable std APIs (no extra deps).
568    fn collect(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
569        if let Ok(entries) = std::fs::read_dir(dir) {
570            for e in entries.flatten() {
571                let p = e.path();
572                if p.is_dir() {
573                    collect(&p, out);
574                } else if p.is_file() {
575                    out.push(p);
576                }
577            }
578        }
579    }
580    collect(&abs, &mut files);
581    files.sort();
582    let mut total = Vec::new();
583    for f in &files {
584        let rel = f
585            .strip_prefix(&abs)
586            .unwrap_or(f)
587            .to_string_lossy()
588            .to_string();
589        total.extend_from_slice(rel.as_bytes());
590        total.push(0);
591        if let Ok(bytes) = std::fs::read(f) {
592            total.extend_from_slice(&bytes);
593        }
594        total.push(0);
595    }
596    hasher.update(&total);
597    format!("sha256:{:x}", hasher.finalize())
598}
599
600/// Verify locked package dependencies against the live manifest and disk.
601///
602/// `base_dir` is the workspace root (`.` in production). Reuses
603/// [`crate::package::validate_dependencies`] to confirm the manifest itself is
604/// well-formed, then compares each manifest dependency to its lock entry:
605///
606/// * source kind + locator match (`path` vs `git`, path/git_url),
607/// * the requested reference (`tag`/`branch`/`rev`) matches,
608/// * local path dependencies still exist on disk,
609/// * Git cache checkouts exist and resolve to the locked commit SHA.
610///
611/// This is **advisory**: it never errors, it reports every drift. A missing
612/// lock yields `present = false` with `consistent = false` and every manifest
613/// dependency listed as `MissingInLock`. Network/registry access is never
614/// required — Git resolution reads only the local `.sdkt-cache` checkout.
615pub fn verify_dependencies(base_dir: &Path, config: &DevKitConfig) -> DepVerifyReport {
616    // Reuse the existing manifest validator rather than re-implementing
617    // dependency validation. If the manifest is malformed we still report the
618    // lock state; the validator's error surfaces through the normal CLI path.
619    let _ = crate::package::validate_dependencies(base_dir, config);
620
621    let Ok(lock) = read_lock(base_dir) else {
622        let mismatches = config
623            .dependencies
624            .keys()
625            .map(|name| DepMismatch {
626                name: name.clone(),
627                kind: DepMismatchKind::MissingInLock,
628                detail: "no sdkt.lock present".to_string(),
629            })
630            .collect();
631        return DepVerifyReport {
632            present: false,
633            consistent: false,
634            checked: config.dependencies.len(),
635            mismatches,
636        };
637    };
638
639    let locked: std::collections::HashMap<&str, &DependencyLock> = lock
640        .dependencies
641        .iter()
642        .map(|d| (d.name.as_str(), d))
643        .collect();
644
645    let mut mismatches: Vec<DepMismatch> = Vec::new();
646    let mut checked = 0;
647
648    for (name, dep) in &config.dependencies {
649        checked += 1;
650        let is_git = dep.git.is_some();
651        let requested_ref = dep
652            .tag
653            .clone()
654            .or_else(|| dep.branch.clone())
655            .or_else(|| dep.rev.clone())
656            .unwrap_or_default();
657
658        let Some(entry) = locked.get(name.as_str()) else {
659            mismatches.push(DepMismatch {
660                name: name.clone(),
661                kind: DepMismatchKind::MissingInLock,
662                detail: "dependency not recorded in lock".to_string(),
663            });
664            continue;
665        };
666
667        // Source kind + locator.
668        let lock_is_git = entry.source == "git";
669        if is_git != lock_is_git {
670            mismatches.push(DepMismatch {
671                name: name.clone(),
672                kind: DepMismatchKind::SourceChanged,
673                detail: format!(
674                    "manifest source is {}, lock recorded {}",
675                    if is_git { "git" } else { "local" },
676                    entry.source
677                ),
678            });
679            continue;
680        }
681        if is_git {
682            if entry.git_url != dep.git.clone().unwrap_or_default() {
683                mismatches.push(DepMismatch {
684                    name: name.clone(),
685                    kind: DepMismatchKind::SourceChanged,
686                    detail: format!(
687                        "git url changed: manifest '{}' vs lock '{}'",
688                        dep.git.clone().unwrap_or_default(),
689                        entry.git_url
690                    ),
691                });
692            }
693            if entry.resolved_reference != requested_ref {
694                mismatches.push(DepMismatch {
695                    name: name.clone(),
696                    kind: DepMismatchKind::ReferenceChanged,
697                    detail: format!(
698                        "reference changed: manifest '{}' vs lock '{}'",
699                        requested_ref, entry.resolved_reference
700                    ),
701                });
702            }
703            // Git cache presence + commit match (only meaningful once fetched).
704            if !entry.commit_sha.is_empty() {
705                let key = crate::fetch::git_cache_key(dep);
706                let checkout = base_dir.join(".sdkt-cache").join("git").join(&key);
707                if !checkout.join(".git").exists() {
708                    mismatches.push(DepMismatch {
709                        name: name.clone(),
710                        kind: DepMismatchKind::CacheMissing,
711                        detail: format!("git cache checkout missing: {}", checkout.display()),
712                    });
713                } else {
714                    let head = git_head_commit(&checkout);
715                    if !head.is_empty() && head != entry.commit_sha {
716                        mismatches.push(DepMismatch {
717                            name: name.clone(),
718                            kind: DepMismatchKind::CommitMismatch,
719                            detail: format!(
720                                "cache commit {} != locked {}",
721                                &head[..head.len().min(12)],
722                                &entry.commit_sha[..entry.commit_sha.len().min(12)]
723                            ),
724                        });
725                    }
726                    // Optional integrity check.
727                    if !entry.integrity.is_empty() {
728                        let current = compute_dependency_integrity(base_dir, dep);
729                        if !current.is_empty() && current != entry.integrity {
730                            mismatches.push(DepMismatch {
731                                name: name.clone(),
732                                kind: DepMismatchKind::IntegrityMismatch,
733                                detail: format!(
734                                    "integrity changed: {} != {}",
735                                    current, entry.integrity
736                                ),
737                            });
738                        }
739                    }
740                }
741            }
742        } else {
743            // Local path dependency: locator match + existence.
744            let resolved = base_dir
745                .join(dep.path.clone().unwrap_or_default())
746                .to_string_lossy()
747                .to_string();
748            if entry.original_source != resolved {
749                mismatches.push(DepMismatch {
750                    name: name.clone(),
751                    kind: DepMismatchKind::SourceChanged,
752                    detail: format!(
753                        "path changed: manifest '{}' vs lock '{}'",
754                        resolved, entry.original_source
755                    ),
756                });
757            } else if !std::path::Path::new(&resolved).exists() {
758                mismatches.push(DepMismatch {
759                    name: name.clone(),
760                    kind: DepMismatchKind::PathMissing,
761                    detail: format!("local path dependency missing: {}", resolved),
762                });
763            }
764        }
765    }
766
767    // Locked deps that no longer appear in the manifest.
768    for entry in &lock.dependencies {
769        if !config.dependencies.contains_key(&entry.name) {
770            mismatches.push(DepMismatch {
771                name: entry.name.clone(),
772                kind: DepMismatchKind::NotInManifest,
773                detail: "lock records a dependency absent from the manifest".to_string(),
774            });
775        }
776    }
777
778    DepVerifyReport {
779        present: true,
780        consistent: mismatches.is_empty(),
781        checked,
782        mismatches,
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::config::{
790        ContractConfig, DecodeConfig, Dependency, DevKitConfig, NetworkConfig, StorageConfig,
791    };
792    use std::collections::HashMap;
793    use std::io::Write;
794
795    fn write_temp_wasm(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
796        let target = dir
797            .join("target")
798            .join("wasm32-unknown-unknown")
799            .join("release");
800        fs::create_dir_all(&target).unwrap();
801        let p = target.join(name);
802        let mut f = fs::File::create(&p).unwrap();
803        f.write_all(bytes).unwrap();
804        p
805    }
806
807    fn config_with(contracts: HashMap<String, ContractConfig>) -> DevKitConfig {
808        DevKitConfig {
809            network: NetworkConfig::default(),
810            decode: DecodeConfig::default(),
811            storage: StorageConfig::default(),
812            contracts,
813            ..Default::default()
814        }
815    }
816
817    #[test]
818    fn compute_sha256_known_vector() {
819        // SHA-256 of empty input.
820        let dir = std::env::temp_dir().join("sdkt_lock_test_empty");
821        let _ = fs::remove_dir_all(&dir);
822        fs::create_dir_all(&dir).unwrap();
823        let p = write_temp_wasm(&dir, "x.wasm", b"");
824        let h = compute_sha256(&p).unwrap();
825        assert_eq!(
826            h,
827            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
828        );
829        let _ = fs::remove_dir_all(&dir);
830    }
831
832    #[test]
833    fn generate_lock_records_order_and_hashes() {
834        let root = std::env::temp_dir().join("sdkt_lock_test_gen");
835        let _ = fs::remove_dir_all(&root);
836        fs::create_dir_all(&root).unwrap();
837
838        let token = root.join("contracts/token");
839        let router = root.join("contracts/router");
840        fs::create_dir_all(&token).unwrap();
841        fs::create_dir_all(&router).unwrap();
842        let token_wasm = write_temp_wasm(&token, "token.wasm", b"token-bytes");
843        let router_wasm = write_temp_wasm(&router, "router.wasm", b"router-bytes");
844
845        let mut map = HashMap::new();
846        map.insert(
847            "token".to_string(),
848            ContractConfig {
849                path: "contracts/token".to_string(),
850                deploy_after: vec![],
851                depends_on: vec![],
852            },
853        );
854        map.insert(
855            "router".to_string(),
856            ContractConfig {
857                path: "contracts/router".to_string(),
858                deploy_after: vec!["token".to_string()],
859                depends_on: vec![],
860            },
861        );
862        let config = config_with(map);
863
864        let lock = generate_lock(&root, &config).unwrap();
865        assert_eq!(lock.version, LOCK_VERSION);
866        // router depends on token => token deployed first.
867        assert_eq!(
868            lock.deploy_order,
869            vec!["token".to_string(), "router".to_string()]
870        );
871        assert_eq!(lock.contracts.len(), 2);
872
873        let token_entry = lock.contracts.iter().find(|c| c.alias == "token").unwrap();
874        assert_eq!(token_entry.order, 0);
875        assert_eq!(token_entry.artifact, token_wasm.to_string_lossy());
876        assert_eq!(token_entry.sha256, compute_sha256(&token_wasm).unwrap());
877
878        let router_entry = lock.contracts.iter().find(|c| c.alias == "router").unwrap();
879        assert_eq!(router_entry.order, 1);
880        assert_eq!(router_entry.artifact, router_wasm.to_string_lossy());
881
882        // Round-trip TOML.
883        let toml = lock_to_toml(&lock).unwrap();
884        let parsed = lock_from_toml(&toml).unwrap();
885        assert_eq!(parsed, lock);
886
887        // Write + read round-trip.
888        let written = write_lock(&root, &lock).unwrap();
889        assert!(written.exists());
890        let reread = read_lock(&root).unwrap();
891        assert_eq!(reread, lock);
892
893        let _ = fs::remove_dir_all(&root);
894    }
895
896    #[test]
897    fn generate_lock_errors_when_artifact_missing() {
898        let root = std::env::temp_dir().join("sdkt_lock_test_missing");
899        let _ = fs::remove_dir_all(&root);
900        fs::create_dir_all(&root).unwrap();
901        let token = root.join("contracts/token");
902        fs::create_dir_all(&token).unwrap(); // no wasm built
903
904        let mut map = HashMap::new();
905        map.insert(
906            "token".to_string(),
907            ContractConfig {
908                path: "contracts/token".to_string(),
909                deploy_after: vec![],
910                depends_on: vec![],
911            },
912        );
913        let config = config_with(map);
914        let res = generate_lock(&root, &config);
915        assert!(matches!(res, Err(LockError::ArtifactNotFound(_))));
916
917        let _ = fs::remove_dir_all(&root);
918    }
919
920    #[test]
921    fn verify_lock_detects_drift_and_missing() {
922        let root = std::env::temp_dir().join("sdkt_lock_test_verify");
923        let _ = fs::remove_dir_all(&root);
924        fs::create_dir_all(&root).unwrap();
925
926        let token = root.join("contracts/token");
927        fs::create_dir_all(&token).unwrap();
928        let token_wasm = write_temp_wasm(&token, "token.wasm", b"original");
929
930        let mut map = HashMap::new();
931        map.insert(
932            "token".to_string(),
933            ContractConfig {
934                path: "contracts/token".to_string(),
935                deploy_after: vec![],
936                depends_on: vec![],
937            },
938        );
939        let config = config_with(map);
940
941        let lock = generate_lock(&root, &config).unwrap();
942        write_lock(&root, &lock).unwrap();
943
944        // Initial verify: consistent.
945        let report = verify_lock(&root, &config);
946        assert!(report.present);
947        assert!(report.consistent);
948        assert!(report.mismatched.is_empty());
949
950        // Mutate the artifact on disk => mismatch.
951        write_temp_wasm(&token, "token.wasm", b"tampered");
952        let report = verify_lock(&root, &config);
953        assert!(report.present);
954        assert!(!report.consistent);
955        assert_eq!(report.mismatched, vec!["token".to_string()]);
956
957        // Remove the artifact entirely => mismatch.
958        let _ = fs::remove_file(&token_wasm);
959        let report = verify_lock(&root, &config);
960        assert!(report.mismatched.contains(&"token".to_string()));
961
962        let _ = fs::remove_dir_all(&root);
963    }
964
965    #[test]
966    fn verify_lock_absent_reports_missing_in_lock() {
967        let root = std::env::temp_dir().join("sdkt_lock_test_no_lock");
968        let _ = fs::remove_dir_all(&root);
969        fs::create_dir_all(&root).unwrap();
970
971        let mut map = HashMap::new();
972        map.insert(
973            "token".to_string(),
974            ContractConfig {
975                path: "contracts/token".to_string(),
976                deploy_after: vec![],
977                depends_on: vec![],
978            },
979        );
980        let config = config_with(map);
981
982        let report = verify_lock(&root, &config);
983        assert!(!report.present);
984        assert!(!report.consistent);
985        assert_eq!(report.missing_in_lock, vec!["token".to_string()]);
986
987        let _ = fs::remove_dir_all(&root);
988    }
989
990    #[test]
991    fn lock_dependencies_records_local_and_git() {
992        use crate::config::Dependency;
993        let mut deps = HashMap::new();
994        deps.insert(
995            "math".to_string(),
996            Dependency {
997                path: Some("libs/math".to_string()),
998                ..Default::default()
999            },
1000        );
1001        deps.insert(
1002            "token".to_string(),
1003            Dependency {
1004                git: Some("https://github.com/org/token".to_string()),
1005                tag: Some("v1.2.0".to_string()),
1006                ..Default::default()
1007            },
1008        );
1009
1010        let locked = lock_dependencies(&std::env::temp_dir(), &deps);
1011        assert_eq!(locked.len(), 2);
1012
1013        let math = locked.iter().find(|d| d.name == "math").unwrap();
1014        assert_eq!(math.source, "local");
1015        assert!(math.git_url.is_empty());
1016
1017        let token = locked.iter().find(|d| d.name == "token").unwrap();
1018        assert_eq!(token.source, "git");
1019        assert_eq!(token.git_url, "https://github.com/org/token");
1020        assert_eq!(token.resolved_reference, "v1.2.0");
1021        assert!(token.commit_sha.is_empty());
1022
1023        // Round-trips through the lock file TOML (empty commit_sha omitted).
1024        let lock = LockFile {
1025            version: LOCK_VERSION,
1026            deploy_order: vec![],
1027            contracts: vec![],
1028            dependencies: locked,
1029        };
1030        let toml = lock_to_toml(&lock).unwrap();
1031        let parsed = lock_from_toml(&toml).unwrap();
1032        assert_eq!(parsed.dependencies, lock.dependencies);
1033    }
1034
1035    #[test]
1036    fn dependency_lock_records_cache_and_integrity() {
1037        use crate::config::Dependency;
1038        let root = std::env::temp_dir().join("sdkt_lock_dep_full");
1039        let _ = fs::remove_dir_all(&root);
1040        fs::create_dir_all(&root).unwrap();
1041
1042        let mut deps = HashMap::new();
1043        deps.insert(
1044            "token".to_string(),
1045            Dependency {
1046                git: Some("https://github.com/org/token".to_string()),
1047                tag: Some("v1.2.0".to_string()),
1048                ..Default::default()
1049            },
1050        );
1051        deps.insert(
1052            "math".to_string(),
1053            Dependency {
1054                path: Some("libs/math".to_string()),
1055                ..Default::default()
1056            },
1057        );
1058        let locked = lock_dependencies(&root, &deps);
1059        let git = locked.iter().find(|d| d.name == "token").unwrap();
1060        assert_eq!(git.source, "git");
1061        assert_eq!(git.original_source, "https://github.com/org/token");
1062        assert_eq!(git.git_url, "https://github.com/org/token");
1063        assert_eq!(git.resolved_reference, "v1.2.0");
1064
1065        let math = locked.iter().find(|d| d.name == "math").unwrap();
1066        assert_eq!(math.source, "local");
1067        assert_eq!(
1068            math.original_source,
1069            root.join("libs/math").to_string_lossy()
1070        );
1071
1072        // Round-trip via lock file TOML, including new fields.
1073        let lock = LockFile {
1074            version: LOCK_VERSION,
1075            deploy_order: vec![],
1076            contracts: vec![],
1077            dependencies: locked,
1078        };
1079        let toml = lock_to_toml(&lock).unwrap();
1080        let parsed = lock_from_toml(&toml).unwrap();
1081        assert_eq!(parsed.dependencies, lock.dependencies);
1082    }
1083
1084    #[test]
1085    fn verify_dependencies_consistent_when_matched() {
1086        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_ok");
1087        let _ = fs::remove_dir_all(&root);
1088        fs::create_dir_all(&root).unwrap();
1089
1090        // Local path dependency that exists on disk.
1091        let lp = root.join("libs/math");
1092        fs::create_dir_all(&lp).unwrap();
1093        fs::write(lp.join("lib.rs"), b"pub fn add(a:u32,b:u32)->u32{a+b}").unwrap();
1094
1095        let mut deps = HashMap::new();
1096        deps.insert(
1097            "math".to_string(),
1098            Dependency {
1099                path: Some("libs/math".to_string()),
1100                ..Default::default()
1101            },
1102        );
1103        let config = config_with_deps(deps);
1104
1105        // No lock yet -> MissingInLock for every dependency.
1106        let rep = verify_dependencies(&root, &config);
1107        assert!(!rep.present);
1108        assert!(!rep.consistent);
1109        assert_eq!(rep.checked, 1);
1110        assert_eq!(rep.mismatches.len(), 1);
1111        assert_eq!(rep.mismatches[0].kind, DepMismatchKind::MissingInLock);
1112
1113        // Write a matching lock, then verification is consistent.
1114        let lock = LockFile {
1115            version: LOCK_VERSION,
1116            deploy_order: vec![],
1117            contracts: vec![],
1118            dependencies: lock_dependencies(&root, &config.dependencies),
1119        };
1120        write_lock(&root, &lock).unwrap();
1121        let rep = verify_dependencies(&root, &config);
1122        assert!(rep.present);
1123        assert!(rep.consistent);
1124        assert!(rep.mismatches.is_empty());
1125
1126        let _ = fs::remove_dir_all(&root);
1127    }
1128
1129    #[test]
1130    fn verify_dependencies_detects_path_missing_and_drift() {
1131        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_drift");
1132        let _ = fs::remove_dir_all(&root);
1133        fs::create_dir_all(&root).unwrap();
1134
1135        let lp = root.join("libs/math");
1136        fs::create_dir_all(&lp).unwrap();
1137        fs::write(lp.join("lib.rs"), b"x").unwrap();
1138
1139        let mut deps = HashMap::new();
1140        deps.insert(
1141            "math".to_string(),
1142            Dependency {
1143                path: Some("libs/math".to_string()),
1144                ..Default::default()
1145            },
1146        );
1147        let config = config_with_deps(deps);
1148        let lock = LockFile {
1149            version: LOCK_VERSION,
1150            deploy_order: vec![],
1151            contracts: vec![],
1152            dependencies: lock_dependencies(&root, &config.dependencies),
1153        };
1154        write_lock(&root, &lock).unwrap();
1155
1156        // Delete the path dependency -> PathMissing.
1157        let _ = fs::remove_dir_all(&lp);
1158        let rep = verify_dependencies(&root, &config);
1159        assert!(!rep.consistent);
1160        assert!(rep
1161            .mismatches
1162            .iter()
1163            .any(|m| m.kind == DepMismatchKind::PathMissing));
1164
1165        // Restore; then change the manifest path -> SourceChanged.
1166        fs::create_dir_all(&lp).unwrap();
1167        let mut deps2 = HashMap::new();
1168        deps2.insert(
1169            "math".to_string(),
1170            Dependency {
1171                path: Some("libs/other".to_string()),
1172                ..Default::default()
1173            },
1174        );
1175        let config2 = config_with_deps(deps2);
1176        let rep = verify_dependencies(&root, &config2);
1177        assert!(!rep.consistent);
1178        assert!(rep
1179            .mismatches
1180            .iter()
1181            .any(|m| m.kind == DepMismatchKind::SourceChanged));
1182
1183        // Lock records a dep absent from the manifest -> NotInManifest.
1184        let mut deps3 = HashMap::new();
1185        deps3.insert(
1186            "stale".to_string(),
1187            Dependency {
1188                path: Some("libs/stale".to_string()),
1189                ..Default::default()
1190            },
1191        );
1192        let config3 = config_with_deps(deps3);
1193        let rep = verify_dependencies(&root, &config3);
1194        assert!(!rep.consistent);
1195        assert!(rep
1196            .mismatches
1197            .iter()
1198            .any(|m| m.kind == DepMismatchKind::NotInManifest));
1199
1200        let _ = fs::remove_dir_all(&root);
1201    }
1202
1203    #[test]
1204    fn verify_dependencies_detects_git_cache_mismatch() {
1205        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_git");
1206        let _ = fs::remove_dir_all(&root);
1207        fs::create_dir_all(&root).unwrap();
1208
1209        let mut deps = HashMap::new();
1210        deps.insert(
1211            "tok".to_string(),
1212            Dependency {
1213                git: Some("https://example.com/org/tok".to_string()),
1214                tag: Some("v2.0.0".to_string()),
1215                ..Default::default()
1216            },
1217        );
1218        let config = config_with_deps(deps);
1219
1220        // Lock claims a resolved commit but cache is absent -> CacheMissing.
1221        let mut lock_dep = lock_dependencies(&root, &config.dependencies);
1222        lock_dep[0].commit_sha = "deadbeef".repeat(5); // 40 hex chars
1223        let lock = LockFile {
1224            version: LOCK_VERSION,
1225            deploy_order: vec![],
1226            contracts: vec![],
1227            dependencies: lock_dep,
1228        };
1229        write_lock(&root, &lock).unwrap();
1230
1231        let rep = verify_dependencies(&root, &config);
1232        assert!(!rep.consistent);
1233        assert!(rep
1234            .mismatches
1235            .iter()
1236            .any(|m| m.kind == DepMismatchKind::CacheMissing));
1237
1238        let _ = fs::remove_dir_all(&root);
1239    }
1240
1241    // Helper: build a DevKitConfig with only package dependencies (no contracts),
1242    // reusing the existing minimal config builder.
1243    fn config_with_deps(dependencies: HashMap<String, Dependency>) -> DevKitConfig {
1244        DevKitConfig {
1245            network: NetworkConfig::default(),
1246            decode: DecodeConfig::default(),
1247            storage: StorageConfig::default(),
1248            contracts: HashMap::new(),
1249            dependencies,
1250            ..Default::default()
1251        }
1252    }
1253}