Skip to main content

sdkt_core/
lock.rs

1//! Project lock-file generation for the Soroban DevKit (sdkt).
2//!
3//! 4.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 ( / / ).
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    /// (). 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 ( / ). 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(digest.iter().map(|b| format!("{b:02x}")).collect())
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(crate::build::WASM_BUILD_TARGET)
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 ().
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 ().
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    let digest = hasher.finalize();
598    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
599    format!("sha256:{hex}")
600}
601
602/// Verify locked package dependencies against the live manifest and disk.
603///
604/// `base_dir` is the workspace root (`.` in production). Reuses
605/// [`crate::package::validate_dependencies`] to confirm the manifest itself is
606/// well-formed, then compares each manifest dependency to its lock entry:
607///
608/// * source kind + locator match (`path` vs `git`, path/git_url),
609/// * the requested reference (`tag`/`branch`/`rev`) matches,
610/// * local path dependencies still exist on disk,
611/// * Git cache checkouts exist and resolve to the locked commit SHA.
612///
613/// This is **advisory**: it never errors, it reports every drift. A missing
614/// lock yields `present = false` with `consistent = false` and every manifest
615/// dependency listed as `MissingInLock`. Network/registry access is never
616/// required — Git resolution reads only the local `.sdkt-cache` checkout.
617pub fn verify_dependencies(base_dir: &Path, config: &DevKitConfig) -> DepVerifyReport {
618    // Reuse the existing manifest validator rather than re-implementing
619    // dependency validation. If the manifest is malformed we still report the
620    // lock state; the validator's error surfaces through the normal CLI path.
621    let _ = crate::package::validate_dependencies(base_dir, config);
622
623    let Ok(lock) = read_lock(base_dir) else {
624        let mismatches = config
625            .dependencies
626            .keys()
627            .map(|name| DepMismatch {
628                name: name.clone(),
629                kind: DepMismatchKind::MissingInLock,
630                detail: "no sdkt.lock present".to_string(),
631            })
632            .collect();
633        return DepVerifyReport {
634            present: false,
635            consistent: false,
636            checked: config.dependencies.len(),
637            mismatches,
638        };
639    };
640
641    let locked: std::collections::HashMap<&str, &DependencyLock> = lock
642        .dependencies
643        .iter()
644        .map(|d| (d.name.as_str(), d))
645        .collect();
646
647    let mut mismatches: Vec<DepMismatch> = Vec::new();
648    let mut checked = 0;
649
650    for (name, dep) in &config.dependencies {
651        checked += 1;
652        let is_git = dep.git.is_some();
653        let requested_ref = dep
654            .tag
655            .clone()
656            .or_else(|| dep.branch.clone())
657            .or_else(|| dep.rev.clone())
658            .unwrap_or_default();
659
660        let Some(entry) = locked.get(name.as_str()) else {
661            mismatches.push(DepMismatch {
662                name: name.clone(),
663                kind: DepMismatchKind::MissingInLock,
664                detail: "dependency not recorded in lock".to_string(),
665            });
666            continue;
667        };
668
669        // Source kind + locator.
670        let lock_is_git = entry.source == "git";
671        if is_git != lock_is_git {
672            mismatches.push(DepMismatch {
673                name: name.clone(),
674                kind: DepMismatchKind::SourceChanged,
675                detail: format!(
676                    "manifest source is {}, lock recorded {}",
677                    if is_git { "git" } else { "local" },
678                    entry.source
679                ),
680            });
681            continue;
682        }
683        if is_git {
684            if entry.git_url != dep.git.clone().unwrap_or_default() {
685                mismatches.push(DepMismatch {
686                    name: name.clone(),
687                    kind: DepMismatchKind::SourceChanged,
688                    detail: format!(
689                        "git url changed: manifest '{}' vs lock '{}'",
690                        dep.git.clone().unwrap_or_default(),
691                        entry.git_url
692                    ),
693                });
694            }
695            if entry.resolved_reference != requested_ref {
696                mismatches.push(DepMismatch {
697                    name: name.clone(),
698                    kind: DepMismatchKind::ReferenceChanged,
699                    detail: format!(
700                        "reference changed: manifest '{}' vs lock '{}'",
701                        requested_ref, entry.resolved_reference
702                    ),
703                });
704            }
705            // Git cache presence + commit match (only meaningful once fetched).
706            if !entry.commit_sha.is_empty() {
707                let key = crate::fetch::git_cache_key(dep);
708                let checkout = base_dir.join(".sdkt-cache").join("git").join(&key);
709                if !checkout.join(".git").exists() {
710                    mismatches.push(DepMismatch {
711                        name: name.clone(),
712                        kind: DepMismatchKind::CacheMissing,
713                        detail: format!("git cache checkout missing: {}", checkout.display()),
714                    });
715                } else {
716                    let head = git_head_commit(&checkout);
717                    if !head.is_empty() && head != entry.commit_sha {
718                        mismatches.push(DepMismatch {
719                            name: name.clone(),
720                            kind: DepMismatchKind::CommitMismatch,
721                            detail: format!(
722                                "cache commit {} != locked {}",
723                                &head[..head.len().min(12)],
724                                &entry.commit_sha[..entry.commit_sha.len().min(12)]
725                            ),
726                        });
727                    }
728                    // Optional integrity check.
729                    if !entry.integrity.is_empty() {
730                        let current = compute_dependency_integrity(base_dir, dep);
731                        if !current.is_empty() && current != entry.integrity {
732                            mismatches.push(DepMismatch {
733                                name: name.clone(),
734                                kind: DepMismatchKind::IntegrityMismatch,
735                                detail: format!(
736                                    "integrity changed: {} != {}",
737                                    current, entry.integrity
738                                ),
739                            });
740                        }
741                    }
742                }
743            }
744        } else {
745            // Local path dependency: locator match + existence.
746            let resolved = base_dir
747                .join(dep.path.clone().unwrap_or_default())
748                .to_string_lossy()
749                .to_string();
750            if entry.original_source != resolved {
751                mismatches.push(DepMismatch {
752                    name: name.clone(),
753                    kind: DepMismatchKind::SourceChanged,
754                    detail: format!(
755                        "path changed: manifest '{}' vs lock '{}'",
756                        resolved, entry.original_source
757                    ),
758                });
759            } else if !std::path::Path::new(&resolved).exists() {
760                mismatches.push(DepMismatch {
761                    name: name.clone(),
762                    kind: DepMismatchKind::PathMissing,
763                    detail: format!("local path dependency missing: {}", resolved),
764                });
765            }
766        }
767    }
768
769    // Locked deps that no longer appear in the manifest.
770    for entry in &lock.dependencies {
771        if !config.dependencies.contains_key(&entry.name) {
772            mismatches.push(DepMismatch {
773                name: entry.name.clone(),
774                kind: DepMismatchKind::NotInManifest,
775                detail: "lock records a dependency absent from the manifest".to_string(),
776            });
777        }
778    }
779
780    DepVerifyReport {
781        present: true,
782        consistent: mismatches.is_empty(),
783        checked,
784        mismatches,
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use crate::config::{
792        ContractConfig, DecodeConfig, Dependency, DevKitConfig, NetworkConfig, StorageConfig,
793    };
794    use std::collections::HashMap;
795    use std::io::Write;
796
797    fn write_temp_wasm(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
798        let target = dir
799            .join("target")
800            .join(crate::build::WASM_BUILD_TARGET)
801            .join("release");
802        fs::create_dir_all(&target).unwrap();
803        let p = target.join(name);
804        let mut f = fs::File::create(&p).unwrap();
805        f.write_all(bytes).unwrap();
806        p
807    }
808
809    fn config_with(contracts: HashMap<String, ContractConfig>) -> DevKitConfig {
810        DevKitConfig {
811            network: NetworkConfig::default(),
812            decode: DecodeConfig::default(),
813            storage: StorageConfig::default(),
814            contracts,
815            ..Default::default()
816        }
817    }
818
819    #[test]
820    fn compute_sha256_known_vector() {
821        // SHA-256 of empty input.
822        let dir = std::env::temp_dir().join("sdkt_lock_test_empty");
823        let _ = fs::remove_dir_all(&dir);
824        fs::create_dir_all(&dir).unwrap();
825        let p = write_temp_wasm(&dir, "x.wasm", b"");
826        let h = compute_sha256(&p).unwrap();
827        assert_eq!(
828            h,
829            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
830        );
831        let _ = fs::remove_dir_all(&dir);
832    }
833
834    #[test]
835    fn generate_lock_records_order_and_hashes() {
836        let root = std::env::temp_dir().join("sdkt_lock_test_gen");
837        let _ = fs::remove_dir_all(&root);
838        fs::create_dir_all(&root).unwrap();
839
840        let token = root.join("contracts/token");
841        let router = root.join("contracts/router");
842        fs::create_dir_all(&token).unwrap();
843        fs::create_dir_all(&router).unwrap();
844        let token_wasm = write_temp_wasm(&token, "token.wasm", b"token-bytes");
845        let router_wasm = write_temp_wasm(&router, "router.wasm", b"router-bytes");
846
847        let mut map = HashMap::new();
848        map.insert(
849            "token".to_string(),
850            ContractConfig {
851                path: "contracts/token".to_string(),
852                deploy_after: vec![],
853                depends_on: vec![],
854            },
855        );
856        map.insert(
857            "router".to_string(),
858            ContractConfig {
859                path: "contracts/router".to_string(),
860                deploy_after: vec!["token".to_string()],
861                depends_on: vec![],
862            },
863        );
864        let config = config_with(map);
865
866        let lock = generate_lock(&root, &config).unwrap();
867        assert_eq!(lock.version, LOCK_VERSION);
868        // router depends on token => token deployed first.
869        assert_eq!(
870            lock.deploy_order,
871            vec!["token".to_string(), "router".to_string()]
872        );
873        assert_eq!(lock.contracts.len(), 2);
874
875        let token_entry = lock.contracts.iter().find(|c| c.alias == "token").unwrap();
876        assert_eq!(token_entry.order, 0);
877        assert_eq!(token_entry.artifact, token_wasm.to_string_lossy());
878        assert_eq!(token_entry.sha256, compute_sha256(&token_wasm).unwrap());
879
880        let router_entry = lock.contracts.iter().find(|c| c.alias == "router").unwrap();
881        assert_eq!(router_entry.order, 1);
882        assert_eq!(router_entry.artifact, router_wasm.to_string_lossy());
883
884        // Round-trip TOML.
885        let toml = lock_to_toml(&lock).unwrap();
886        let parsed = lock_from_toml(&toml).unwrap();
887        assert_eq!(parsed, lock);
888
889        // Write + read round-trip.
890        let written = write_lock(&root, &lock).unwrap();
891        assert!(written.exists());
892        let reread = read_lock(&root).unwrap();
893        assert_eq!(reread, lock);
894
895        let _ = fs::remove_dir_all(&root);
896    }
897
898    #[test]
899    fn generate_lock_errors_when_artifact_missing() {
900        let root = std::env::temp_dir().join("sdkt_lock_test_missing");
901        let _ = fs::remove_dir_all(&root);
902        fs::create_dir_all(&root).unwrap();
903        let token = root.join("contracts/token");
904        fs::create_dir_all(&token).unwrap(); // no wasm built
905
906        let mut map = HashMap::new();
907        map.insert(
908            "token".to_string(),
909            ContractConfig {
910                path: "contracts/token".to_string(),
911                deploy_after: vec![],
912                depends_on: vec![],
913            },
914        );
915        let config = config_with(map);
916        let res = generate_lock(&root, &config);
917        assert!(matches!(res, Err(LockError::ArtifactNotFound(_))));
918
919        let _ = fs::remove_dir_all(&root);
920    }
921
922    #[test]
923    fn verify_lock_detects_drift_and_missing() {
924        let root = std::env::temp_dir().join("sdkt_lock_test_verify");
925        let _ = fs::remove_dir_all(&root);
926        fs::create_dir_all(&root).unwrap();
927
928        let token = root.join("contracts/token");
929        fs::create_dir_all(&token).unwrap();
930        let token_wasm = write_temp_wasm(&token, "token.wasm", b"original");
931
932        let mut map = HashMap::new();
933        map.insert(
934            "token".to_string(),
935            ContractConfig {
936                path: "contracts/token".to_string(),
937                deploy_after: vec![],
938                depends_on: vec![],
939            },
940        );
941        let config = config_with(map);
942
943        let lock = generate_lock(&root, &config).unwrap();
944        write_lock(&root, &lock).unwrap();
945
946        // Initial verify: consistent.
947        let report = verify_lock(&root, &config);
948        assert!(report.present);
949        assert!(report.consistent);
950        assert!(report.mismatched.is_empty());
951
952        // Mutate the artifact on disk => mismatch.
953        write_temp_wasm(&token, "token.wasm", b"tampered");
954        let report = verify_lock(&root, &config);
955        assert!(report.present);
956        assert!(!report.consistent);
957        assert_eq!(report.mismatched, vec!["token".to_string()]);
958
959        // Remove the artifact entirely => mismatch.
960        let _ = fs::remove_file(&token_wasm);
961        let report = verify_lock(&root, &config);
962        assert!(report.mismatched.contains(&"token".to_string()));
963
964        let _ = fs::remove_dir_all(&root);
965    }
966
967    #[test]
968    fn verify_lock_absent_reports_missing_in_lock() {
969        let root = std::env::temp_dir().join("sdkt_lock_test_no_lock");
970        let _ = fs::remove_dir_all(&root);
971        fs::create_dir_all(&root).unwrap();
972
973        let mut map = HashMap::new();
974        map.insert(
975            "token".to_string(),
976            ContractConfig {
977                path: "contracts/token".to_string(),
978                deploy_after: vec![],
979                depends_on: vec![],
980            },
981        );
982        let config = config_with(map);
983
984        let report = verify_lock(&root, &config);
985        assert!(!report.present);
986        assert!(!report.consistent);
987        assert_eq!(report.missing_in_lock, vec!["token".to_string()]);
988
989        let _ = fs::remove_dir_all(&root);
990    }
991
992    #[test]
993    fn lock_dependencies_records_local_and_git() {
994        use crate::config::Dependency;
995        let mut deps = HashMap::new();
996        deps.insert(
997            "math".to_string(),
998            Dependency {
999                path: Some("libs/math".to_string()),
1000                ..Default::default()
1001            },
1002        );
1003        deps.insert(
1004            "token".to_string(),
1005            Dependency {
1006                git: Some("https://github.com/org/token".to_string()),
1007                tag: Some("v1.2.0".to_string()),
1008                ..Default::default()
1009            },
1010        );
1011
1012        let locked = lock_dependencies(&std::env::temp_dir(), &deps);
1013        assert_eq!(locked.len(), 2);
1014
1015        let math = locked.iter().find(|d| d.name == "math").unwrap();
1016        assert_eq!(math.source, "local");
1017        assert!(math.git_url.is_empty());
1018
1019        let token = locked.iter().find(|d| d.name == "token").unwrap();
1020        assert_eq!(token.source, "git");
1021        assert_eq!(token.git_url, "https://github.com/org/token");
1022        assert_eq!(token.resolved_reference, "v1.2.0");
1023        assert!(token.commit_sha.is_empty());
1024
1025        // Round-trips through the lock file TOML (empty commit_sha omitted).
1026        let lock = LockFile {
1027            version: LOCK_VERSION,
1028            deploy_order: vec![],
1029            contracts: vec![],
1030            dependencies: locked,
1031        };
1032        let toml = lock_to_toml(&lock).unwrap();
1033        let parsed = lock_from_toml(&toml).unwrap();
1034        assert_eq!(parsed.dependencies, lock.dependencies);
1035    }
1036
1037    #[test]
1038    fn dependency_lock_records_cache_and_integrity() {
1039        use crate::config::Dependency;
1040        let root = std::env::temp_dir().join("sdkt_lock_dep_full");
1041        let _ = fs::remove_dir_all(&root);
1042        fs::create_dir_all(&root).unwrap();
1043
1044        let mut deps = HashMap::new();
1045        deps.insert(
1046            "token".to_string(),
1047            Dependency {
1048                git: Some("https://github.com/org/token".to_string()),
1049                tag: Some("v1.2.0".to_string()),
1050                ..Default::default()
1051            },
1052        );
1053        deps.insert(
1054            "math".to_string(),
1055            Dependency {
1056                path: Some("libs/math".to_string()),
1057                ..Default::default()
1058            },
1059        );
1060        let locked = lock_dependencies(&root, &deps);
1061        let git = locked.iter().find(|d| d.name == "token").unwrap();
1062        assert_eq!(git.source, "git");
1063        assert_eq!(git.original_source, "https://github.com/org/token");
1064        assert_eq!(git.git_url, "https://github.com/org/token");
1065        assert_eq!(git.resolved_reference, "v1.2.0");
1066
1067        let math = locked.iter().find(|d| d.name == "math").unwrap();
1068        assert_eq!(math.source, "local");
1069        assert_eq!(
1070            math.original_source,
1071            root.join("libs/math").to_string_lossy()
1072        );
1073
1074        // Round-trip via lock file TOML, including new fields.
1075        let lock = LockFile {
1076            version: LOCK_VERSION,
1077            deploy_order: vec![],
1078            contracts: vec![],
1079            dependencies: locked,
1080        };
1081        let toml = lock_to_toml(&lock).unwrap();
1082        let parsed = lock_from_toml(&toml).unwrap();
1083        assert_eq!(parsed.dependencies, lock.dependencies);
1084    }
1085
1086    #[test]
1087    fn verify_dependencies_consistent_when_matched() {
1088        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_ok");
1089        let _ = fs::remove_dir_all(&root);
1090        fs::create_dir_all(&root).unwrap();
1091
1092        // Local path dependency that exists on disk.
1093        let lp = root.join("libs/math");
1094        fs::create_dir_all(&lp).unwrap();
1095        fs::write(lp.join("lib.rs"), b"pub fn add(a:u32,b:u32)->u32{a+b}").unwrap();
1096
1097        let mut deps = HashMap::new();
1098        deps.insert(
1099            "math".to_string(),
1100            Dependency {
1101                path: Some("libs/math".to_string()),
1102                ..Default::default()
1103            },
1104        );
1105        let config = config_with_deps(deps);
1106
1107        // No lock yet -> MissingInLock for every dependency.
1108        let rep = verify_dependencies(&root, &config);
1109        assert!(!rep.present);
1110        assert!(!rep.consistent);
1111        assert_eq!(rep.checked, 1);
1112        assert_eq!(rep.mismatches.len(), 1);
1113        assert_eq!(rep.mismatches[0].kind, DepMismatchKind::MissingInLock);
1114
1115        // Write a matching lock, then verification is consistent.
1116        let lock = LockFile {
1117            version: LOCK_VERSION,
1118            deploy_order: vec![],
1119            contracts: vec![],
1120            dependencies: lock_dependencies(&root, &config.dependencies),
1121        };
1122        write_lock(&root, &lock).unwrap();
1123        let rep = verify_dependencies(&root, &config);
1124        assert!(rep.present);
1125        assert!(rep.consistent);
1126        assert!(rep.mismatches.is_empty());
1127
1128        let _ = fs::remove_dir_all(&root);
1129    }
1130
1131    #[test]
1132    fn verify_dependencies_detects_path_missing_and_drift() {
1133        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_drift");
1134        let _ = fs::remove_dir_all(&root);
1135        fs::create_dir_all(&root).unwrap();
1136
1137        let lp = root.join("libs/math");
1138        fs::create_dir_all(&lp).unwrap();
1139        fs::write(lp.join("lib.rs"), b"x").unwrap();
1140
1141        let mut deps = HashMap::new();
1142        deps.insert(
1143            "math".to_string(),
1144            Dependency {
1145                path: Some("libs/math".to_string()),
1146                ..Default::default()
1147            },
1148        );
1149        let config = config_with_deps(deps);
1150        let lock = LockFile {
1151            version: LOCK_VERSION,
1152            deploy_order: vec![],
1153            contracts: vec![],
1154            dependencies: lock_dependencies(&root, &config.dependencies),
1155        };
1156        write_lock(&root, &lock).unwrap();
1157
1158        // Delete the path dependency -> PathMissing.
1159        let _ = fs::remove_dir_all(&lp);
1160        let rep = verify_dependencies(&root, &config);
1161        assert!(!rep.consistent);
1162        assert!(rep
1163            .mismatches
1164            .iter()
1165            .any(|m| m.kind == DepMismatchKind::PathMissing));
1166
1167        // Restore; then change the manifest path -> SourceChanged.
1168        fs::create_dir_all(&lp).unwrap();
1169        let mut deps2 = HashMap::new();
1170        deps2.insert(
1171            "math".to_string(),
1172            Dependency {
1173                path: Some("libs/other".to_string()),
1174                ..Default::default()
1175            },
1176        );
1177        let config2 = config_with_deps(deps2);
1178        let rep = verify_dependencies(&root, &config2);
1179        assert!(!rep.consistent);
1180        assert!(rep
1181            .mismatches
1182            .iter()
1183            .any(|m| m.kind == DepMismatchKind::SourceChanged));
1184
1185        // Lock records a dep absent from the manifest -> NotInManifest.
1186        let mut deps3 = HashMap::new();
1187        deps3.insert(
1188            "stale".to_string(),
1189            Dependency {
1190                path: Some("libs/stale".to_string()),
1191                ..Default::default()
1192            },
1193        );
1194        let config3 = config_with_deps(deps3);
1195        let rep = verify_dependencies(&root, &config3);
1196        assert!(!rep.consistent);
1197        assert!(rep
1198            .mismatches
1199            .iter()
1200            .any(|m| m.kind == DepMismatchKind::NotInManifest));
1201
1202        let _ = fs::remove_dir_all(&root);
1203    }
1204
1205    #[test]
1206    fn verify_dependencies_detects_git_cache_mismatch() {
1207        let root = std::env::temp_dir().join("sdkt_lock_dep_verify_git");
1208        let _ = fs::remove_dir_all(&root);
1209        fs::create_dir_all(&root).unwrap();
1210
1211        let mut deps = HashMap::new();
1212        deps.insert(
1213            "tok".to_string(),
1214            Dependency {
1215                git: Some("https://example.com/org/tok".to_string()),
1216                tag: Some("v2.0.0".to_string()),
1217                ..Default::default()
1218            },
1219        );
1220        let config = config_with_deps(deps);
1221
1222        // Lock claims a resolved commit but cache is absent -> CacheMissing.
1223        let mut lock_dep = lock_dependencies(&root, &config.dependencies);
1224        lock_dep[0].commit_sha = "deadbeef".repeat(5); // 40 hex chars
1225        let lock = LockFile {
1226            version: LOCK_VERSION,
1227            deploy_order: vec![],
1228            contracts: vec![],
1229            dependencies: lock_dep,
1230        };
1231        write_lock(&root, &lock).unwrap();
1232
1233        let rep = verify_dependencies(&root, &config);
1234        assert!(!rep.consistent);
1235        assert!(rep
1236            .mismatches
1237            .iter()
1238            .any(|m| m.kind == DepMismatchKind::CacheMissing));
1239
1240        let _ = fs::remove_dir_all(&root);
1241    }
1242
1243    // Helper: build a DevKitConfig with only package dependencies (no contracts),
1244    // reusing the existing minimal config builder.
1245    fn config_with_deps(dependencies: HashMap<String, Dependency>) -> DevKitConfig {
1246        DevKitConfig {
1247            network: NetworkConfig::default(),
1248            decode: DecodeConfig::default(),
1249            storage: StorageConfig::default(),
1250            contracts: HashMap::new(),
1251            dependencies,
1252            ..Default::default()
1253        }
1254    }
1255}