1use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::path::Path;
19
20use super::{PkgError, PkgResult};
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct Lockfile {
25 pub version: u32,
29
30 pub stryke: String,
32
33 pub resolved: String,
36
37 #[serde(default, rename = "package")]
40 pub packages: Vec<LockedPackage>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct LockedPackage {
46 pub name: String,
47 pub version: String,
48 pub source: String,
50 pub integrity: String,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub features: Vec<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub deps: Vec<String>,
59}
60
61impl Lockfile {
62 pub fn new() -> Lockfile {
64 Lockfile {
65 version: 1,
66 stryke: env!("CARGO_PKG_VERSION").to_string(),
67 resolved: current_utc_timestamp(),
68 packages: Vec::new(),
69 }
70 }
71
72 #[allow(clippy::should_implement_trait)]
76 pub fn from_str(s: &str) -> PkgResult<Lockfile> {
77 toml::from_str::<Lockfile>(s)
78 .map_err(|e| PkgError::Lockfile(format!("stryke.lock: {}", e.message())))
79 }
80
81 pub fn from_path(path: &Path) -> PkgResult<Lockfile> {
83 let s = std::fs::read_to_string(path)
84 .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
85 Lockfile::from_str(&s)
86 }
87
88 pub fn to_toml_string(&mut self) -> PkgResult<String> {
91 self.canonicalize();
92 let body = toml::to_string_pretty(&self)
93 .map_err(|e| PkgError::Lockfile(format!("serialize stryke.lock: {}", e)))?;
94 Ok(format!("# Auto-generated. Do not edit.\n{}", body))
95 }
96
97 pub fn canonicalize(&mut self) {
99 self.packages
100 .sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));
101 for p in &mut self.packages {
102 p.deps.sort();
103 p.features.sort();
104 p.features.dedup();
105 }
106 }
107
108 pub fn find(&self, name: &str) -> Option<&LockedPackage> {
111 self.packages.iter().find(|p| p.name == name)
112 }
113}
114
115pub fn integrity_for_bytes(bytes: &[u8]) -> String {
117 let mut h = Sha256::new();
118 h.update(bytes);
119 format!("sha256-{:x}", h.finalize())
120}
121
122pub fn integrity_for_directory(root: &Path) -> PkgResult<String> {
131 let mut hasher = Sha256::new();
132 let mut entries: Vec<std::path::PathBuf> = Vec::new();
133 walk_collect(root, root, &mut entries)?;
134 entries.sort();
135 for rel in &entries {
136 let abs = root.join(rel);
137 let meta = std::fs::symlink_metadata(&abs)?;
138 let rel_s = rel.to_string_lossy();
139 if meta.file_type().is_symlink() {
140 let target = std::fs::read_link(&abs)?;
141 hasher.update(rel_s.as_bytes());
142 hasher.update(b"\0L\0");
143 hasher.update(target.to_string_lossy().as_bytes());
144 hasher.update(b"\n");
145 } else if meta.is_file() {
146 let bytes = std::fs::read(&abs)?;
147 hasher.update(rel_s.as_bytes());
148 hasher.update(b"\0F\0");
149 hasher.update(bytes.len().to_string().as_bytes());
150 hasher.update(b"\n");
151 hasher.update(&bytes);
152 hasher.update(b"\n");
153 }
154 }
155 Ok(format!("sha256-{:x}", hasher.finalize()))
156}
157
158fn walk_collect(root: &Path, cur: &Path, out: &mut Vec<std::path::PathBuf>) -> PkgResult<()> {
159 for entry in std::fs::read_dir(cur)? {
160 let entry = entry?;
161 let path = entry.path();
162 let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
163 let meta = entry.metadata()?;
164 if meta.is_dir() && !meta.file_type().is_symlink() {
165 walk_collect(root, &path, out)?;
166 } else {
167 out.push(rel);
168 }
169 }
170 Ok(())
171}
172
173fn current_utc_timestamp() -> String {
176 use std::time::{SystemTime, UNIX_EPOCH};
177 let secs = SystemTime::now()
178 .duration_since(UNIX_EPOCH)
179 .map(|d| d.as_secs())
180 .unwrap_or(0);
181 format_iso_utc(secs)
182}
183
184fn format_iso_utc(unix_secs: u64) -> String {
185 let days = (unix_secs / 86_400) as i64;
187 let secs_of_day = unix_secs % 86_400;
188 let h = secs_of_day / 3600;
189 let m = (secs_of_day % 3600) / 60;
190 let s = secs_of_day % 60;
191 let (year, month, day) = days_to_ymd(days);
192 format!(
193 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
194 year, month, day, h, m, s
195 )
196}
197
198fn days_to_ymd(days_since_epoch: i64) -> (i32, u32, u32) {
200 let z = days_since_epoch + 719_468;
201 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
202 let doe = (z - era * 146_097) as u64;
203 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
204 let y = yoe as i64 + era * 400;
205 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
206 let mp = (5 * doy + 2) / 153;
207 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
208 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
209 let y = if m <= 2 { y + 1 } else { y };
210 (y as i32, m, d)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn integrity_is_deterministic() {
219 let bytes = b"hello world";
220 let a = integrity_for_bytes(bytes);
221 let b = integrity_for_bytes(bytes);
222 assert_eq!(a, b);
223 assert!(a.starts_with("sha256-"));
224 }
225
226 #[test]
227 fn directory_integrity_changes_on_content_change() {
228 let tmp = tempdir();
229 std::fs::write(tmp.join("a.txt"), b"v1").unwrap();
230 let h1 = integrity_for_directory(&tmp).unwrap();
231 std::fs::write(tmp.join("a.txt"), b"v2").unwrap();
232 let h2 = integrity_for_directory(&tmp).unwrap();
233 assert_ne!(h1, h2);
234 }
235
236 #[test]
237 fn directory_integrity_stable_across_runs() {
238 let tmp = tempdir();
239 std::fs::write(tmp.join("a.txt"), b"v1").unwrap();
240 std::fs::write(tmp.join("b.txt"), b"v2").unwrap();
241 let h1 = integrity_for_directory(&tmp).unwrap();
242 let h2 = integrity_for_directory(&tmp).unwrap();
243 assert_eq!(h1, h2);
244 }
245
246 #[test]
247 fn lockfile_round_trip() {
248 let mut lf = Lockfile::new();
249 lf.packages.push(LockedPackage {
250 name: "json".into(),
251 version: "2.1.0".into(),
252 source: "registry+https://registry.stryke.dev".into(),
253 integrity: "sha256-abc123".into(),
254 features: vec![],
255 deps: vec![],
256 });
257 lf.packages.push(LockedPackage {
258 name: "http".into(),
259 version: "1.0.0".into(),
260 source: "registry+https://registry.stryke.dev".into(),
261 integrity: "sha256-def456".into(),
262 features: vec!["default".into()],
263 deps: vec!["json@2.1.0".into()],
264 });
265 let out = lf.to_toml_string().unwrap();
266 let http_pos = out.find("name = \"http\"").unwrap();
268 let json_pos = out.find("name = \"json\"").unwrap();
269 assert!(http_pos < json_pos);
270 let lf2 = Lockfile::from_str(&out).unwrap();
271 assert_eq!(lf2.packages.len(), 2);
272 }
273
274 #[test]
275 fn iso_utc_format_matches_pattern() {
276 let s = format_iso_utc(0);
277 assert_eq!(s, "1970-01-01T00:00:00Z");
278 let s = format_iso_utc(1_700_000_000);
279 assert!(s.starts_with("2023-"));
280 assert!(s.ends_with("Z"));
281 }
282
283 fn tempdir() -> std::path::PathBuf {
286 let pid = std::process::id();
287 let nanos = std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .unwrap()
290 .subsec_nanos();
291 let p = std::env::temp_dir().join(format!("stryke-pkg-test-{}-{}", pid, nanos));
292 let _ = std::fs::remove_dir_all(&p);
293 std::fs::create_dir_all(&p).unwrap();
294 p
295 }
296}