use crate::config::DevKitConfig;
use crate::project::{resolve_deploy_order, ProjectError};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockEntry {
pub alias: String,
pub path: String,
pub artifact: String,
pub sha256: String,
pub order: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DependencyLock {
pub name: String,
pub source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub original_source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub git_url: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub resolved_reference: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit_sha: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub cache_location: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub integrity: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockFile {
pub version: u32,
pub deploy_order: Vec<String>,
pub contracts: Vec<LockEntry>,
#[serde(default)]
pub dependencies: Vec<DependencyLock>,
}
#[derive(Debug)]
pub enum LockError {
Project(ProjectError),
ArtifactNotFound(String),
Io {
alias: String,
source: std::io::Error,
},
Write {
path: PathBuf,
source: Box<dyn std::error::Error>,
},
Parse {
path: PathBuf,
source: Box<dyn std::error::Error>,
},
}
impl fmt::Display for LockError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LockError::Project(e) => write!(f, "project resolution failed: {}", e),
LockError::ArtifactNotFound(alias) => {
write!(f, "no WASM artifact found for contract '{}'", alias)
}
LockError::Io { alias, source } => {
write!(f, "I/O error reading artifact for '{}': {}", alias, source)
}
LockError::Write { path, source } => {
write!(
f,
"failed to write lock file {}: {}",
path.display(),
source
)
}
LockError::Parse { path, source } => {
write!(
f,
"failed to parse lock file {}: {}",
path.display(),
source
)
}
}
}
}
impl std::error::Error for LockError {}
impl From<ProjectError> for LockError {
fn from(e: ProjectError) -> Self {
LockError::Project(e)
}
}
pub const LOCK_VERSION: u32 = 1;
pub const LOCK_FILE_NAME: &str = "sdkt.lock";
pub fn compute_sha256(path: &Path) -> Result<String, LockError> {
use sha2::Digest;
let bytes = fs::read(path).map_err(|source| LockError::Io {
alias: path.display().to_string(),
source,
})?;
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
Ok(format!("{:x}", digest))
}
fn find_artifact(base_dir: &Path, contract_path: &str) -> Option<PathBuf> {
let base = base_dir.join(contract_path);
let target_dir = base
.join("target")
.join("wasm32-unknown-unknown")
.join("release");
if !target_dir.exists() {
return None;
}
let entries = fs::read_dir(&target_dir).ok()?;
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|ext| ext == "wasm") {
return Some(p);
}
}
None
}
pub fn generate_lock(base_dir: &Path, config: &DevKitConfig) -> Result<LockFile, LockError> {
let ordered = resolve_deploy_order(config)?;
let mut contracts = Vec::with_capacity(ordered.len());
for (order, alias) in ordered.iter().enumerate() {
let cfg = config
.contracts
.get(alias)
.expect("alias came from config.contracts");
let artifact = find_artifact(base_dir, &cfg.path)
.ok_or_else(|| LockError::ArtifactNotFound(alias.clone()))?;
let sha256 = compute_sha256(&artifact)?;
let artifact_str = artifact.to_string_lossy().to_string();
contracts.push(LockEntry {
alias: alias.clone(),
path: cfg.path.clone(),
artifact: artifact_str,
sha256,
order,
});
}
Ok(LockFile {
version: LOCK_VERSION,
deploy_order: ordered,
contracts,
dependencies: lock_dependencies(base_dir, &config.dependencies),
})
}
pub fn lock_dependencies(
base_dir: &Path,
deps: &std::collections::HashMap<String, crate::config::Dependency>,
) -> Vec<DependencyLock> {
let mut out = Vec::with_capacity(deps.len());
for (name, dep) in deps {
if dep.git.is_some() {
let reference = dep
.tag
.clone()
.or_else(|| dep.branch.clone())
.or_else(|| dep.rev.clone())
.unwrap_or_default();
out.push(DependencyLock {
name: name.clone(),
source: "git".to_string(),
original_source: dep.git.clone().unwrap_or_default(),
git_url: dep.git.clone().unwrap_or_default(),
resolved_reference: reference,
commit_sha: String::new(),
cache_location: String::new(),
integrity: String::new(),
version: dep.version.clone().unwrap_or_default(),
});
} else {
let path = dep.path.clone().unwrap_or_default();
let resolved = if path.is_empty() {
path.clone()
} else {
base_dir.join(&path).to_string_lossy().to_string()
};
out.push(DependencyLock {
name: name.clone(),
source: "local".to_string(),
original_source: resolved,
git_url: String::new(),
resolved_reference: String::new(),
commit_sha: String::new(),
cache_location: String::new(),
integrity: String::new(),
version: String::new(),
});
}
}
out
}
pub fn lock_dependencies_resolved(
base_dir: &Path,
config: &DevKitConfig,
fetched: &[crate::fetch::FetchOutcome],
) -> Vec<DependencyLock> {
let mut out = Vec::with_capacity(config.dependencies.len());
for (name, dep) in &config.dependencies {
if let Some(outcome) = fetched.iter().find(|o| o.name == *name) {
let (source, original_source, git_url, resolved_reference) = if dep.git.is_some() {
(
"git".to_string(),
dep.git.clone().unwrap_or_default(),
dep.git.clone().unwrap_or_default(),
dep.tag
.clone()
.or_else(|| dep.branch.clone())
.or_else(|| dep.rev.clone())
.unwrap_or_default(),
)
} else {
let resolved = base_dir
.join(dep.path.clone().unwrap_or_default())
.to_string_lossy()
.to_string();
("local".to_string(), resolved, String::new(), String::new())
};
let integrity = compute_dependency_integrity(base_dir, dep);
out.push(DependencyLock {
name: name.clone(),
source,
original_source,
git_url,
resolved_reference,
commit_sha: outcome.resolved_rev.clone(),
cache_location: outcome.local_path.display().to_string(),
integrity,
version: dep.version.clone().unwrap_or_default(),
});
} else {
out.push(
lock_dependencies(base_dir, &config.dependencies)
.into_iter()
.find(|d| d.name == *name)
.unwrap_or_else(|| DependencyLock {
name: name.clone(),
..Default::default()
}),
);
}
}
out
}
pub fn locked_dependencies(lock: &LockFile) -> &[DependencyLock] {
&lock.dependencies
}
pub fn lock_to_toml(lock: &LockFile) -> Result<String, LockError> {
toml::to_string_pretty(lock).map_err(|source| LockError::Write {
path: PathBuf::from(LOCK_FILE_NAME),
source: Box::new(source),
})
}
pub fn write_lock(dir: &Path, lock: &LockFile) -> Result<PathBuf, LockError> {
let toml = lock_to_toml(lock)?;
let path = dir.join(LOCK_FILE_NAME);
fs::write(&path, toml).map_err(|source| LockError::Write {
path: path.clone(),
source: Box::new(source),
})?;
Ok(path)
}
pub fn lock_from_toml(content: &str) -> Result<LockFile, LockError> {
toml::from_str(content).map_err(|source| LockError::Parse {
path: PathBuf::from(LOCK_FILE_NAME),
source: Box::new(source),
})
}
pub fn read_lock(dir: &Path) -> Result<LockFile, LockError> {
let path = dir.join(LOCK_FILE_NAME);
let content = fs::read_to_string(&path).map_err(|source| LockError::Parse {
path: path.clone(),
source: Box::new(source),
})?;
lock_from_toml(&content)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockVerifyReport {
pub present: bool,
pub consistent: bool,
pub mismatched: Vec<String>,
pub missing_in_lock: Vec<String>,
}
pub fn verify_lock(base_dir: &Path, config: &DevKitConfig) -> LockVerifyReport {
let Ok(lock) = read_lock(base_dir) else {
let missing_in_lock = config.contracts.keys().cloned().collect();
return LockVerifyReport {
present: false,
consistent: false,
mismatched: vec![],
missing_in_lock,
};
};
let mut mismatched = Vec::new();
let locked_aliases: std::collections::HashSet<String> =
lock.contracts.iter().map(|c| c.alias.clone()).collect();
let mut missing_in_lock = Vec::new();
for alias in config.contracts.keys() {
if !locked_aliases.contains(alias) {
missing_in_lock.push(alias.clone());
}
}
for entry in &lock.contracts {
let artifact = base_dir.join(&entry.artifact);
let Ok(current) = compute_sha256(&artifact) else {
mismatched.push(entry.alias.clone());
continue;
};
if current != entry.sha256 {
mismatched.push(entry.alias.clone());
}
}
let consistent = mismatched.is_empty() && missing_in_lock.is_empty();
LockVerifyReport {
present: true,
consistent,
mismatched,
missing_in_lock,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DepMismatchKind {
MissingInLock,
NotInManifest,
SourceChanged,
ReferenceChanged,
CacheMissing,
PathMissing,
CommitMismatch,
IntegrityMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepMismatch {
pub name: String,
pub kind: DepMismatchKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepVerifyReport {
pub present: bool,
pub consistent: bool,
pub checked: usize,
pub mismatches: Vec<DepMismatch>,
}
fn git_head_commit(checkout: &Path) -> String {
let out = std::process::Command::new(crate::fetch::git_bin())
.current_dir(checkout)
.args(["rev-parse", "HEAD"])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => String::new(),
}
}
pub fn compute_dependency_integrity(base_dir: &Path, dep: &crate::config::Dependency) -> String {
if let Some(_git) = &dep.git {
let key = crate::fetch::git_cache_key(dep);
let checkout = base_dir.join(".sdkt-cache").join("git").join(&key);
if checkout.join(".git").exists() {
let out = std::process::Command::new(crate::fetch::git_bin())
.current_dir(&checkout)
.args(["rev-parse", "HEAD^{tree}"])
.output();
if let Ok(o) = out {
if o.status.success() {
let tree = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !tree.is_empty() {
return format!("sha256:{}", tree);
}
}
}
}
return String::new();
}
let path = dep.path.clone().unwrap_or_default();
if path.is_empty() {
return String::new();
}
let abs = base_dir.join(&path);
if !abs.is_dir() {
return String::new();
}
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
let mut files = Vec::new();
fn collect(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
collect(&p, out);
} else if p.is_file() {
out.push(p);
}
}
}
}
collect(&abs, &mut files);
files.sort();
let mut total = Vec::new();
for f in &files {
let rel = f
.strip_prefix(&abs)
.unwrap_or(f)
.to_string_lossy()
.to_string();
total.extend_from_slice(rel.as_bytes());
total.push(0);
if let Ok(bytes) = std::fs::read(f) {
total.extend_from_slice(&bytes);
}
total.push(0);
}
hasher.update(&total);
format!("sha256:{:x}", hasher.finalize())
}
pub fn verify_dependencies(base_dir: &Path, config: &DevKitConfig) -> DepVerifyReport {
let _ = crate::package::validate_dependencies(base_dir, config);
let Ok(lock) = read_lock(base_dir) else {
let mismatches = config
.dependencies
.keys()
.map(|name| DepMismatch {
name: name.clone(),
kind: DepMismatchKind::MissingInLock,
detail: "no sdkt.lock present".to_string(),
})
.collect();
return DepVerifyReport {
present: false,
consistent: false,
checked: config.dependencies.len(),
mismatches,
};
};
let locked: std::collections::HashMap<&str, &DependencyLock> = lock
.dependencies
.iter()
.map(|d| (d.name.as_str(), d))
.collect();
let mut mismatches: Vec<DepMismatch> = Vec::new();
let mut checked = 0;
for (name, dep) in &config.dependencies {
checked += 1;
let is_git = dep.git.is_some();
let requested_ref = dep
.tag
.clone()
.or_else(|| dep.branch.clone())
.or_else(|| dep.rev.clone())
.unwrap_or_default();
let Some(entry) = locked.get(name.as_str()) else {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::MissingInLock,
detail: "dependency not recorded in lock".to_string(),
});
continue;
};
let lock_is_git = entry.source == "git";
if is_git != lock_is_git {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::SourceChanged,
detail: format!(
"manifest source is {}, lock recorded {}",
if is_git { "git" } else { "local" },
entry.source
),
});
continue;
}
if is_git {
if entry.git_url != dep.git.clone().unwrap_or_default() {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::SourceChanged,
detail: format!(
"git url changed: manifest '{}' vs lock '{}'",
dep.git.clone().unwrap_or_default(),
entry.git_url
),
});
}
if entry.resolved_reference != requested_ref {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::ReferenceChanged,
detail: format!(
"reference changed: manifest '{}' vs lock '{}'",
requested_ref, entry.resolved_reference
),
});
}
if !entry.commit_sha.is_empty() {
let key = crate::fetch::git_cache_key(dep);
let checkout = base_dir.join(".sdkt-cache").join("git").join(&key);
if !checkout.join(".git").exists() {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::CacheMissing,
detail: format!("git cache checkout missing: {}", checkout.display()),
});
} else {
let head = git_head_commit(&checkout);
if !head.is_empty() && head != entry.commit_sha {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::CommitMismatch,
detail: format!(
"cache commit {} != locked {}",
&head[..head.len().min(12)],
&entry.commit_sha[..entry.commit_sha.len().min(12)]
),
});
}
if !entry.integrity.is_empty() {
let current = compute_dependency_integrity(base_dir, dep);
if !current.is_empty() && current != entry.integrity {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::IntegrityMismatch,
detail: format!(
"integrity changed: {} != {}",
current, entry.integrity
),
});
}
}
}
}
} else {
let resolved = base_dir
.join(dep.path.clone().unwrap_or_default())
.to_string_lossy()
.to_string();
if entry.original_source != resolved {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::SourceChanged,
detail: format!(
"path changed: manifest '{}' vs lock '{}'",
resolved, entry.original_source
),
});
} else if !std::path::Path::new(&resolved).exists() {
mismatches.push(DepMismatch {
name: name.clone(),
kind: DepMismatchKind::PathMissing,
detail: format!("local path dependency missing: {}", resolved),
});
}
}
}
for entry in &lock.dependencies {
if !config.dependencies.contains_key(&entry.name) {
mismatches.push(DepMismatch {
name: entry.name.clone(),
kind: DepMismatchKind::NotInManifest,
detail: "lock records a dependency absent from the manifest".to_string(),
});
}
}
DepVerifyReport {
present: true,
consistent: mismatches.is_empty(),
checked,
mismatches,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
ContractConfig, DecodeConfig, Dependency, DevKitConfig, NetworkConfig, StorageConfig,
};
use std::collections::HashMap;
use std::io::Write;
fn write_temp_wasm(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
let target = dir
.join("target")
.join("wasm32-unknown-unknown")
.join("release");
fs::create_dir_all(&target).unwrap();
let p = target.join(name);
let mut f = fs::File::create(&p).unwrap();
f.write_all(bytes).unwrap();
p
}
fn config_with(contracts: HashMap<String, ContractConfig>) -> DevKitConfig {
DevKitConfig {
network: NetworkConfig::default(),
decode: DecodeConfig::default(),
storage: StorageConfig::default(),
contracts,
..Default::default()
}
}
#[test]
fn compute_sha256_known_vector() {
let dir = std::env::temp_dir().join("sdkt_lock_test_empty");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let p = write_temp_wasm(&dir, "x.wasm", b"");
let h = compute_sha256(&p).unwrap();
assert_eq!(
h,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn generate_lock_records_order_and_hashes() {
let root = std::env::temp_dir().join("sdkt_lock_test_gen");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let token = root.join("contracts/token");
let router = root.join("contracts/router");
fs::create_dir_all(&token).unwrap();
fs::create_dir_all(&router).unwrap();
let token_wasm = write_temp_wasm(&token, "token.wasm", b"token-bytes");
let router_wasm = write_temp_wasm(&router, "router.wasm", b"router-bytes");
let mut map = HashMap::new();
map.insert(
"token".to_string(),
ContractConfig {
path: "contracts/token".to_string(),
deploy_after: vec![],
depends_on: vec![],
},
);
map.insert(
"router".to_string(),
ContractConfig {
path: "contracts/router".to_string(),
deploy_after: vec!["token".to_string()],
depends_on: vec![],
},
);
let config = config_with(map);
let lock = generate_lock(&root, &config).unwrap();
assert_eq!(lock.version, LOCK_VERSION);
assert_eq!(
lock.deploy_order,
vec!["token".to_string(), "router".to_string()]
);
assert_eq!(lock.contracts.len(), 2);
let token_entry = lock.contracts.iter().find(|c| c.alias == "token").unwrap();
assert_eq!(token_entry.order, 0);
assert_eq!(token_entry.artifact, token_wasm.to_string_lossy());
assert_eq!(token_entry.sha256, compute_sha256(&token_wasm).unwrap());
let router_entry = lock.contracts.iter().find(|c| c.alias == "router").unwrap();
assert_eq!(router_entry.order, 1);
assert_eq!(router_entry.artifact, router_wasm.to_string_lossy());
let toml = lock_to_toml(&lock).unwrap();
let parsed = lock_from_toml(&toml).unwrap();
assert_eq!(parsed, lock);
let written = write_lock(&root, &lock).unwrap();
assert!(written.exists());
let reread = read_lock(&root).unwrap();
assert_eq!(reread, lock);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn generate_lock_errors_when_artifact_missing() {
let root = std::env::temp_dir().join("sdkt_lock_test_missing");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let token = root.join("contracts/token");
fs::create_dir_all(&token).unwrap();
let mut map = HashMap::new();
map.insert(
"token".to_string(),
ContractConfig {
path: "contracts/token".to_string(),
deploy_after: vec![],
depends_on: vec![],
},
);
let config = config_with(map);
let res = generate_lock(&root, &config);
assert!(matches!(res, Err(LockError::ArtifactNotFound(_))));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_lock_detects_drift_and_missing() {
let root = std::env::temp_dir().join("sdkt_lock_test_verify");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let token = root.join("contracts/token");
fs::create_dir_all(&token).unwrap();
let token_wasm = write_temp_wasm(&token, "token.wasm", b"original");
let mut map = HashMap::new();
map.insert(
"token".to_string(),
ContractConfig {
path: "contracts/token".to_string(),
deploy_after: vec![],
depends_on: vec![],
},
);
let config = config_with(map);
let lock = generate_lock(&root, &config).unwrap();
write_lock(&root, &lock).unwrap();
let report = verify_lock(&root, &config);
assert!(report.present);
assert!(report.consistent);
assert!(report.mismatched.is_empty());
write_temp_wasm(&token, "token.wasm", b"tampered");
let report = verify_lock(&root, &config);
assert!(report.present);
assert!(!report.consistent);
assert_eq!(report.mismatched, vec!["token".to_string()]);
let _ = fs::remove_file(&token_wasm);
let report = verify_lock(&root, &config);
assert!(report.mismatched.contains(&"token".to_string()));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_lock_absent_reports_missing_in_lock() {
let root = std::env::temp_dir().join("sdkt_lock_test_no_lock");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let mut map = HashMap::new();
map.insert(
"token".to_string(),
ContractConfig {
path: "contracts/token".to_string(),
deploy_after: vec![],
depends_on: vec![],
},
);
let config = config_with(map);
let report = verify_lock(&root, &config);
assert!(!report.present);
assert!(!report.consistent);
assert_eq!(report.missing_in_lock, vec!["token".to_string()]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn lock_dependencies_records_local_and_git() {
use crate::config::Dependency;
let mut deps = HashMap::new();
deps.insert(
"math".to_string(),
Dependency {
path: Some("libs/math".to_string()),
..Default::default()
},
);
deps.insert(
"token".to_string(),
Dependency {
git: Some("https://github.com/org/token".to_string()),
tag: Some("v1.2.0".to_string()),
..Default::default()
},
);
let locked = lock_dependencies(&std::env::temp_dir(), &deps);
assert_eq!(locked.len(), 2);
let math = locked.iter().find(|d| d.name == "math").unwrap();
assert_eq!(math.source, "local");
assert!(math.git_url.is_empty());
let token = locked.iter().find(|d| d.name == "token").unwrap();
assert_eq!(token.source, "git");
assert_eq!(token.git_url, "https://github.com/org/token");
assert_eq!(token.resolved_reference, "v1.2.0");
assert!(token.commit_sha.is_empty());
let lock = LockFile {
version: LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: locked,
};
let toml = lock_to_toml(&lock).unwrap();
let parsed = lock_from_toml(&toml).unwrap();
assert_eq!(parsed.dependencies, lock.dependencies);
}
#[test]
fn dependency_lock_records_cache_and_integrity() {
use crate::config::Dependency;
let root = std::env::temp_dir().join("sdkt_lock_dep_full");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let mut deps = HashMap::new();
deps.insert(
"token".to_string(),
Dependency {
git: Some("https://github.com/org/token".to_string()),
tag: Some("v1.2.0".to_string()),
..Default::default()
},
);
deps.insert(
"math".to_string(),
Dependency {
path: Some("libs/math".to_string()),
..Default::default()
},
);
let locked = lock_dependencies(&root, &deps);
let git = locked.iter().find(|d| d.name == "token").unwrap();
assert_eq!(git.source, "git");
assert_eq!(git.original_source, "https://github.com/org/token");
assert_eq!(git.git_url, "https://github.com/org/token");
assert_eq!(git.resolved_reference, "v1.2.0");
let math = locked.iter().find(|d| d.name == "math").unwrap();
assert_eq!(math.source, "local");
assert_eq!(
math.original_source,
root.join("libs/math").to_string_lossy()
);
let lock = LockFile {
version: LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: locked,
};
let toml = lock_to_toml(&lock).unwrap();
let parsed = lock_from_toml(&toml).unwrap();
assert_eq!(parsed.dependencies, lock.dependencies);
}
#[test]
fn verify_dependencies_consistent_when_matched() {
let root = std::env::temp_dir().join("sdkt_lock_dep_verify_ok");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let lp = root.join("libs/math");
fs::create_dir_all(&lp).unwrap();
fs::write(lp.join("lib.rs"), b"pub fn add(a:u32,b:u32)->u32{a+b}").unwrap();
let mut deps = HashMap::new();
deps.insert(
"math".to_string(),
Dependency {
path: Some("libs/math".to_string()),
..Default::default()
},
);
let config = config_with_deps(deps);
let rep = verify_dependencies(&root, &config);
assert!(!rep.present);
assert!(!rep.consistent);
assert_eq!(rep.checked, 1);
assert_eq!(rep.mismatches.len(), 1);
assert_eq!(rep.mismatches[0].kind, DepMismatchKind::MissingInLock);
let lock = LockFile {
version: LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: lock_dependencies(&root, &config.dependencies),
};
write_lock(&root, &lock).unwrap();
let rep = verify_dependencies(&root, &config);
assert!(rep.present);
assert!(rep.consistent);
assert!(rep.mismatches.is_empty());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_dependencies_detects_path_missing_and_drift() {
let root = std::env::temp_dir().join("sdkt_lock_dep_verify_drift");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let lp = root.join("libs/math");
fs::create_dir_all(&lp).unwrap();
fs::write(lp.join("lib.rs"), b"x").unwrap();
let mut deps = HashMap::new();
deps.insert(
"math".to_string(),
Dependency {
path: Some("libs/math".to_string()),
..Default::default()
},
);
let config = config_with_deps(deps);
let lock = LockFile {
version: LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: lock_dependencies(&root, &config.dependencies),
};
write_lock(&root, &lock).unwrap();
let _ = fs::remove_dir_all(&lp);
let rep = verify_dependencies(&root, &config);
assert!(!rep.consistent);
assert!(rep
.mismatches
.iter()
.any(|m| m.kind == DepMismatchKind::PathMissing));
fs::create_dir_all(&lp).unwrap();
let mut deps2 = HashMap::new();
deps2.insert(
"math".to_string(),
Dependency {
path: Some("libs/other".to_string()),
..Default::default()
},
);
let config2 = config_with_deps(deps2);
let rep = verify_dependencies(&root, &config2);
assert!(!rep.consistent);
assert!(rep
.mismatches
.iter()
.any(|m| m.kind == DepMismatchKind::SourceChanged));
let mut deps3 = HashMap::new();
deps3.insert(
"stale".to_string(),
Dependency {
path: Some("libs/stale".to_string()),
..Default::default()
},
);
let config3 = config_with_deps(deps3);
let rep = verify_dependencies(&root, &config3);
assert!(!rep.consistent);
assert!(rep
.mismatches
.iter()
.any(|m| m.kind == DepMismatchKind::NotInManifest));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_dependencies_detects_git_cache_mismatch() {
let root = std::env::temp_dir().join("sdkt_lock_dep_verify_git");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let mut deps = HashMap::new();
deps.insert(
"tok".to_string(),
Dependency {
git: Some("https://example.com/org/tok".to_string()),
tag: Some("v2.0.0".to_string()),
..Default::default()
},
);
let config = config_with_deps(deps);
let mut lock_dep = lock_dependencies(&root, &config.dependencies);
lock_dep[0].commit_sha = "deadbeef".repeat(5); let lock = LockFile {
version: LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: lock_dep,
};
write_lock(&root, &lock).unwrap();
let rep = verify_dependencies(&root, &config);
assert!(!rep.consistent);
assert!(rep
.mismatches
.iter()
.any(|m| m.kind == DepMismatchKind::CacheMissing));
let _ = fs::remove_dir_all(&root);
}
fn config_with_deps(dependencies: HashMap<String, Dependency>) -> DevKitConfig {
DevKitConfig {
network: NetworkConfig::default(),
decode: DecodeConfig::default(),
storage: StorageConfig::default(),
contracts: HashMap::new(),
dependencies,
..Default::default()
}
}
}