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(format!("{:x}", digest))
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("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
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 format!("sha256:{:x}", hasher.finalize())
598}
599
600pub fn verify_dependencies(base_dir: &Path, config: &DevKitConfig) -> DepVerifyReport {
616 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 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 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 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 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 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 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 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 let toml = lock_to_toml(&lock).unwrap();
884 let parsed = lock_from_toml(&toml).unwrap();
885 assert_eq!(parsed, lock);
886
887 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(); 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 let report = verify_lock(&root, &config);
946 assert!(report.present);
947 assert!(report.consistent);
948 assert!(report.mismatched.is_empty());
949
950 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 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 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 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 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 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 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 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 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 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 let mut lock_dep = lock_dependencies(&root, &config.dependencies);
1222 lock_dep[0].commit_sha = "deadbeef".repeat(5); 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 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}