1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct LockEntry {
25 pub alias: String,
27 pub path: String,
29 pub artifact: String,
31 pub sha256: String,
33 pub order: usize,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
46pub struct DependencyLock {
47 pub name: String,
49 pub source: String,
51 #[serde(default, skip_serializing_if = "String::is_empty")]
54 pub original_source: String,
55 #[serde(default, skip_serializing_if = "String::is_empty")]
57 pub git_url: String,
58 #[serde(default, skip_serializing_if = "String::is_empty")]
60 pub resolved_reference: String,
61 #[serde(default, skip_serializing_if = "String::is_empty")]
63 pub commit_sha: String,
64 #[serde(default, skip_serializing_if = "String::is_empty")]
67 pub cache_location: String,
68 #[serde(default, skip_serializing_if = "String::is_empty")]
71 pub integrity: String,
72 #[serde(default, skip_serializing_if = "String::is_empty")]
76 pub version: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct LockFile {
82 pub version: u32,
84 pub deploy_order: Vec<String>,
86 pub contracts: Vec<LockEntry>,
88 #[serde(default)]
90 pub dependencies: Vec<DependencyLock>,
91}
92
93#[derive(Debug)]
95pub enum LockError {
96 Project(ProjectError),
98 ArtifactNotFound(String),
100 Io {
102 alias: String,
103 source: std::io::Error,
104 },
105 Write {
107 path: PathBuf,
108 source: Box<dyn std::error::Error>,
109 },
110 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
155pub const LOCK_VERSION: u32 = 1;
157
158pub const LOCK_FILE_NAME: &str = "sdkt.lock";
160
161pub 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
174fn 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
196pub 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 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
236pub 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 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
295pub 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 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
361pub 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
372pub 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
383pub 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
391pub 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#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct LockVerifyReport {
404 pub present: bool,
406 pub consistent: bool,
408 pub mismatched: Vec<String>,
410 pub missing_in_lock: Vec<String>,
412}
413
414pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum DepMismatchKind {
468 MissingInLock,
470 NotInManifest,
472 SourceChanged,
474 ReferenceChanged,
476 CacheMissing,
478 PathMissing,
480 CommitMismatch,
482 IntegrityMismatch,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct DepMismatch {
489 pub name: String,
491 pub kind: DepMismatchKind,
493 pub detail: String,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct DepVerifyReport {
501 pub present: bool,
503 pub consistent: bool,
505 pub checked: usize,
507 pub mismatches: Vec<DepMismatch>,
509}
510
511fn 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
525pub 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 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 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
602pub fn verify_dependencies(base_dir: &Path, config: &DevKitConfig) -> DepVerifyReport {
618 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 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 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 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 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 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 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 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 let toml = lock_to_toml(&lock).unwrap();
886 let parsed = lock_from_toml(&toml).unwrap();
887 assert_eq!(parsed, lock);
888
889 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(); 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 let report = verify_lock(&root, &config);
948 assert!(report.present);
949 assert!(report.consistent);
950 assert!(report.mismatched.is_empty());
951
952 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 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 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 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 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 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 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 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 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 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 let mut lock_dep = lock_dependencies(&root, &config.dependencies);
1224 lock_dep[0].commit_sha = "deadbeef".repeat(5); 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 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}