Skip to main content

stryke/pkg/
lockfile.rs

1//! `stryke.lock` — auto-generated, deterministic, sacred. RFC §"Lock File".
2//!
3//! Two installs of the same lockfile on different machines must produce
4//! byte-identical store contents. We keep this contract by:
5//!
6//! 1. Sorting `[[package]]` entries by `name`, then by `version`.
7//! 2. Sorting transitive `deps = [...]` lists alphabetically.
8//! 3. Pinning every dep's source URL and SHA-256 integrity hash.
9//! 4. Recording the resolver/format version (`version = 1`).
10//!
11//! The lockfile is regenerated explicitly via `s install` / `s update`. It is
12//! never edited by hand and never silently rewritten when only `stryke.toml`
13//! changed (consumers running `s install` against an existing lock get the
14//! pinned versions; `s add`/`s remove`/`s update` are the explicit edit paths).
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::path::Path;
19
20use super::{PkgError, PkgResult};
21
22/// Top-level `stryke.lock` shape.
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct Lockfile {
25    /// Lockfile schema version. Bumped whenever we change layout in a
26    /// non-backwards-compatible way; older lockfiles can still be read by
27    /// migration shims keyed on this value.
28    pub version: u32,
29
30    /// Stryke compiler version that wrote this lockfile (audit trail).
31    pub stryke: String,
32
33    /// ISO-8601 UTC timestamp of resolution. Recorded for audit; not used as
34    /// part of the integrity contract.
35    pub resolved: String,
36
37    /// One entry per `(name, version)` in the resolved graph.
38    /// Field name `package` keeps the human-readable `[[package]]` form in TOML.
39    #[serde(default, rename = "package")]
40    pub packages: Vec<LockedPackage>,
41}
42
43/// One resolved package in the lock graph.
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct LockedPackage {
46    /// `name` field.
47    pub name: String,
48    /// `version` field.
49    pub version: String,
50    /// Source URL — `registry+https://...`, `path+file://...`, `git+https://...#REV`.
51    pub source: String,
52    /// SHA-256 of the canonical content (tarball for registry/git, recursive
53    /// directory hash for path deps). Format: `"sha256-<hex>"`.
54    pub integrity: String,
55    /// Feature flags enabled for this package in this resolution.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub features: Vec<String>,
58    /// Transitive deps as `name@version` strings. Sorted for determinism.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub deps: Vec<String>,
61}
62
63impl Lockfile {
64    /// Construct a fresh empty lockfile stamped with the current stryke version.
65    pub fn new() -> Lockfile {
66        Lockfile {
67            version: 1,
68            stryke: env!("CARGO_PKG_VERSION").to_string(),
69            resolved: current_utc_timestamp(),
70            packages: Vec::new(),
71        }
72    }
73
74    /// Parse from a string. Kept as an inherent method (rather than `impl FromStr`)
75    /// so the error type can be the rich `PkgError::Lockfile` rather than the
76    /// trait's restricted single-type associate.
77    #[allow(clippy::should_implement_trait)]
78    pub fn from_str(s: &str) -> PkgResult<Lockfile> {
79        toml::from_str::<Lockfile>(s)
80            .map_err(|e| PkgError::Lockfile(format!("stryke.lock: {}", e.message())))
81    }
82
83    /// Parse from a file path.
84    pub fn from_path(path: &Path) -> PkgResult<Lockfile> {
85        let s = std::fs::read_to_string(path)
86            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
87        Lockfile::from_str(&s)
88    }
89
90    /// Serialize. Sorts packages and their `deps` lists in place first so the
91    /// output is bit-stable across resolver runs that produce equivalent graphs.
92    pub fn to_toml_string(&mut self) -> PkgResult<String> {
93        self.canonicalize();
94        let body = toml::to_string_pretty(&self)
95            .map_err(|e| PkgError::Lockfile(format!("serialize stryke.lock: {}", e)))?;
96        Ok(format!("# Auto-generated. Do not edit.\n{}", body))
97    }
98
99    /// Sort packages and per-package `deps` lists for determinism. Idempotent.
100    pub fn canonicalize(&mut self) {
101        self.packages
102            .sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));
103        for p in &mut self.packages {
104            p.deps.sort();
105            p.features.sort();
106            p.features.dedup();
107        }
108    }
109
110    /// Look up a package entry by name. Returns the first match (lockfile is a
111    /// flat resolution — one (name, version) per name post-resolve).
112    pub fn find(&self, name: &str) -> Option<&LockedPackage> {
113        self.packages.iter().find(|p| p.name == name)
114    }
115}
116
117/// Compute a SHA-256 of a single byte slice and format as `"sha256-<hex>"`.
118pub fn integrity_for_bytes(bytes: &[u8]) -> String {
119    let mut h = Sha256::new();
120    h.update(bytes);
121    format!("sha256-{:x}", h.finalize())
122}
123
124/// Compute a deterministic content hash of a directory tree. Used for path-dep
125/// integrity pinning. Hash inputs are walked in sorted order so the result is
126/// stable regardless of filesystem iteration order.
127///
128/// Entries are hashed as `<relpath>\0<size>\n<contents>` per file, with `\0`
129/// separators between entries. Directories are descended; symlinks are read
130/// as their target path string (no follow). Hidden files (`.` prefix) are
131/// included; this is content addressing, not packaging policy.
132pub fn integrity_for_directory(root: &Path) -> PkgResult<String> {
133    let mut hasher = Sha256::new();
134    let mut entries: Vec<std::path::PathBuf> = Vec::new();
135    walk_collect(root, root, &mut entries)?;
136    entries.sort();
137    for rel in &entries {
138        let abs = root.join(rel);
139        let meta = std::fs::symlink_metadata(&abs)?;
140        let rel_s = rel.to_string_lossy();
141        if meta.file_type().is_symlink() {
142            let target = std::fs::read_link(&abs)?;
143            hasher.update(rel_s.as_bytes());
144            hasher.update(b"\0L\0");
145            hasher.update(target.to_string_lossy().as_bytes());
146            hasher.update(b"\n");
147        } else if meta.is_file() {
148            let bytes = std::fs::read(&abs)?;
149            hasher.update(rel_s.as_bytes());
150            hasher.update(b"\0F\0");
151            hasher.update(bytes.len().to_string().as_bytes());
152            hasher.update(b"\n");
153            hasher.update(&bytes);
154            hasher.update(b"\n");
155        }
156    }
157    Ok(format!("sha256-{:x}", hasher.finalize()))
158}
159
160fn walk_collect(root: &Path, cur: &Path, out: &mut Vec<std::path::PathBuf>) -> PkgResult<()> {
161    for entry in std::fs::read_dir(cur)? {
162        let entry = entry?;
163        let path = entry.path();
164        let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
165        let meta = entry.metadata()?;
166        if meta.is_dir() && !meta.file_type().is_symlink() {
167            walk_collect(root, &path, out)?;
168        } else {
169            out.push(rel);
170        }
171    }
172    Ok(())
173}
174
175/// ISO-8601 UTC timestamp using `std::time::SystemTime`. We don't pull `chrono`
176/// just for this — a minimal `YYYY-MM-DDTHH:MM:SSZ` formatter is sufficient.
177fn current_utc_timestamp() -> String {
178    use std::time::{SystemTime, UNIX_EPOCH};
179    let secs = SystemTime::now()
180        .duration_since(UNIX_EPOCH)
181        .map(|d| d.as_secs())
182        .unwrap_or(0);
183    format_iso_utc(secs)
184}
185
186fn format_iso_utc(unix_secs: u64) -> String {
187    // Days since 1970-01-01.
188    let days = (unix_secs / 86_400) as i64;
189    let secs_of_day = unix_secs % 86_400;
190    let h = secs_of_day / 3600;
191    let m = (secs_of_day % 3600) / 60;
192    let s = secs_of_day % 60;
193    let (year, month, day) = days_to_ymd(days);
194    format!(
195        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
196        year, month, day, h, m, s
197    )
198}
199
200/// Civil from days — Howard Hinnant's algorithm, public domain.
201fn days_to_ymd(days_since_epoch: i64) -> (i32, u32, u32) {
202    let z = days_since_epoch + 719_468;
203    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
204    let doe = (z - era * 146_097) as u64;
205    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
206    let y = yoe as i64 + era * 400;
207    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
208    let mp = (5 * doy + 2) / 153;
209    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
210    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
211    let y = if m <= 2 { y + 1 } else { y };
212    (y as i32, m, d)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn integrity_is_deterministic() {
221        let bytes = b"hello world";
222        let a = integrity_for_bytes(bytes);
223        let b = integrity_for_bytes(bytes);
224        assert_eq!(a, b);
225        assert!(a.starts_with("sha256-"));
226    }
227
228    #[test]
229    fn directory_integrity_changes_on_content_change() {
230        let tmp = tempdir();
231        std::fs::write(tmp.join("a.txt"), b"v1").unwrap();
232        let h1 = integrity_for_directory(&tmp).unwrap();
233        std::fs::write(tmp.join("a.txt"), b"v2").unwrap();
234        let h2 = integrity_for_directory(&tmp).unwrap();
235        assert_ne!(h1, h2);
236    }
237
238    #[test]
239    fn directory_integrity_stable_across_runs() {
240        let tmp = tempdir();
241        std::fs::write(tmp.join("a.txt"), b"v1").unwrap();
242        std::fs::write(tmp.join("b.txt"), b"v2").unwrap();
243        let h1 = integrity_for_directory(&tmp).unwrap();
244        let h2 = integrity_for_directory(&tmp).unwrap();
245        assert_eq!(h1, h2);
246    }
247
248    #[test]
249    fn lockfile_round_trip() {
250        let mut lf = Lockfile::new();
251        lf.packages.push(LockedPackage {
252            name: "json".into(),
253            version: "2.1.0".into(),
254            source: "registry+https://registry.stryke.dev".into(),
255            integrity: "sha256-abc123".into(),
256            features: vec![],
257            deps: vec![],
258        });
259        lf.packages.push(LockedPackage {
260            name: "http".into(),
261            version: "1.0.0".into(),
262            source: "registry+https://registry.stryke.dev".into(),
263            integrity: "sha256-def456".into(),
264            features: vec!["default".into()],
265            deps: vec!["json@2.1.0".into()],
266        });
267        let out = lf.to_toml_string().unwrap();
268        // After canonicalization, http (alphabetical) precedes json.
269        let http_pos = out.find("name = \"http\"").unwrap();
270        let json_pos = out.find("name = \"json\"").unwrap();
271        assert!(http_pos < json_pos);
272        let lf2 = Lockfile::from_str(&out).unwrap();
273        assert_eq!(lf2.packages.len(), 2);
274    }
275
276    #[test]
277    fn iso_utc_format_matches_pattern() {
278        let s = format_iso_utc(0);
279        assert_eq!(s, "1970-01-01T00:00:00Z");
280        let s = format_iso_utc(1_700_000_000);
281        assert!(s.starts_with("2023-"));
282        assert!(s.ends_with("Z"));
283    }
284
285    /// Tiny tempdir helper — `tempfile` not in deps, and we just need a unique
286    /// path under `target/` that gets dropped after the test.
287    fn tempdir() -> std::path::PathBuf {
288        let pid = std::process::id();
289        let nanos = std::time::SystemTime::now()
290            .duration_since(std::time::UNIX_EPOCH)
291            .unwrap()
292            .subsec_nanos();
293        let p = std::env::temp_dir().join(format!("stryke-pkg-test-{}-{}", pid, nanos));
294        let _ = std::fs::remove_dir_all(&p);
295        std::fs::create_dir_all(&p).unwrap();
296        p
297    }
298}