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    pub name: String,
47    pub version: String,
48    /// Source URL — `registry+https://...`, `path+file://...`, `git+https://...#REV`.
49    pub source: String,
50    /// SHA-256 of the canonical content (tarball for registry/git, recursive
51    /// directory hash for path deps). Format: `"sha256-<hex>"`.
52    pub integrity: String,
53    /// Feature flags enabled for this package in this resolution.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub features: Vec<String>,
56    /// Transitive deps as `name@version` strings. Sorted for determinism.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub deps: Vec<String>,
59}
60
61impl Lockfile {
62    /// Construct a fresh empty lockfile stamped with the current stryke version.
63    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    /// Parse from a string. Kept as an inherent method (rather than `impl FromStr`)
73    /// so the error type can be the rich `PkgError::Lockfile` rather than the
74    /// trait's restricted single-type associate.
75    #[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    /// Parse from a file path.
82    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    /// Serialize. Sorts packages and their `deps` lists in place first so the
89    /// output is bit-stable across resolver runs that produce equivalent graphs.
90    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    /// Sort packages and per-package `deps` lists for determinism. Idempotent.
98    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    /// Look up a package entry by name. Returns the first match (lockfile is a
109    /// flat resolution — one (name, version) per name post-resolve).
110    pub fn find(&self, name: &str) -> Option<&LockedPackage> {
111        self.packages.iter().find(|p| p.name == name)
112    }
113}
114
115/// Compute a SHA-256 of a single byte slice and format as `"sha256-<hex>"`.
116pub 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
122/// Compute a deterministic content hash of a directory tree. Used for path-dep
123/// integrity pinning. Hash inputs are walked in sorted order so the result is
124/// stable regardless of filesystem iteration order.
125///
126/// Entries are hashed as `<relpath>\0<size>\n<contents>` per file, with `\0`
127/// separators between entries. Directories are descended; symlinks are read
128/// as their target path string (no follow). Hidden files (`.` prefix) are
129/// included; this is content addressing, not packaging policy.
130pub 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
173/// ISO-8601 UTC timestamp using `std::time::SystemTime`. We don't pull `chrono`
174/// just for this — a minimal `YYYY-MM-DDTHH:MM:SSZ` formatter is sufficient.
175fn 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    // Days since 1970-01-01.
186    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
198/// Civil from days — Howard Hinnant's algorithm, public domain.
199fn 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        // After canonicalization, http (alphabetical) precedes json.
267        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    /// Tiny tempdir helper — `tempfile` not in deps, and we just need a unique
284    /// path under `target/` that gets dropped after the test.
285    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}