Skip to main content

assay_registry/
lockfile.rs

1//! Lockfile support for reproducible builds.
2//!
3//! The lockfile (`assay.packs.lock`) records exact pack versions and digests
4//! to ensure reproducible builds across machines and CI runs.
5//!
6//! # Lockfile Format (v2)
7//!
8//! ```yaml
9//! version: 2
10//! generated_at: "2026-01-29T10:00:00Z"
11//! generated_by: "assay-cli/2.10.1"
12//! packs:
13//!   - name: eu-ai-act-pro
14//!     version: "1.2.0"
15//!     digest: sha256:abc123...
16//!     source: registry
17//!     registry_url: "https://registry.getassay.dev/v1"
18//!     signature:
19//!       algorithm: Ed25519
20//!       key_id: sha256:def456...
21//! ```
22
23use std::path::Path;
24
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27
28#[cfg(test)]
29use crate::error::RegistryError;
30use crate::error::RegistryResult;
31use crate::resolver::PackResolver;
32
33#[path = "lockfile_next/mod.rs"]
34mod lockfile_next;
35
36/// Default lockfile name.
37pub const LOCKFILE_NAME: &str = "assay.packs.lock";
38
39/// Current lockfile schema version.
40pub const LOCKFILE_VERSION: u8 = 2;
41
42/// A pack lockfile.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Lockfile {
45    /// Schema version.
46    pub version: u8,
47
48    /// When the lockfile was generated.
49    pub generated_at: DateTime<Utc>,
50
51    /// Tool that generated the lockfile.
52    pub generated_by: String,
53
54    /// Locked packs.
55    #[serde(default)]
56    pub packs: Vec<LockedPack>,
57}
58
59/// A locked pack entry.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct LockedPack {
62    /// Pack name.
63    pub name: String,
64
65    /// Pack version.
66    pub version: String,
67
68    /// Content digest (sha256:...).
69    pub digest: String,
70
71    /// Source type.
72    pub source: LockSource,
73
74    /// Registry URL (if source is registry).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub registry_url: Option<String>,
77
78    /// BYOS URL (if source is byos).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub byos_url: Option<String>,
81
82    /// Signature information (if signed).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub signature: Option<LockSignature>,
85}
86
87/// Source type for locked packs.
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "lowercase")]
90pub enum LockSource {
91    /// Bundled pack.
92    Bundled,
93
94    /// Registry pack.
95    Registry,
96
97    /// BYOS pack.
98    Byos,
99
100    /// Local file (not recommended for lockfiles).
101    Local,
102}
103
104/// Signature information.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct LockSignature {
107    /// Signature algorithm.
108    pub algorithm: String,
109
110    /// Key ID used for signing.
111    pub key_id: String,
112}
113
114/// Lockfile verification result.
115#[derive(Debug, Clone)]
116pub struct VerifyLockResult {
117    /// Whether all packs match.
118    pub all_match: bool,
119
120    /// Packs that matched.
121    pub matched: Vec<String>,
122
123    /// Packs with digest mismatches.
124    pub mismatched: Vec<LockMismatch>,
125
126    /// Packs in lockfile but not resolved.
127    pub missing: Vec<String>,
128
129    /// Packs resolved but not in lockfile.
130    pub extra: Vec<String>,
131}
132
133/// A lockfile mismatch.
134#[derive(Debug, Clone)]
135pub struct LockMismatch {
136    /// Pack name.
137    pub name: String,
138
139    /// Pack version.
140    pub version: String,
141
142    /// Expected digest from lockfile.
143    pub expected: String,
144
145    /// Actual digest from resolution.
146    pub actual: String,
147}
148
149impl Lockfile {
150    /// Create a new empty lockfile.
151    pub fn new() -> Self {
152        Self {
153            version: LOCKFILE_VERSION,
154            generated_at: Utc::now(),
155            generated_by: format!("assay-cli/{}", env!("CARGO_PKG_VERSION")),
156            packs: Vec::new(),
157        }
158    }
159
160    /// Load a lockfile from a path.
161    pub async fn load(path: impl AsRef<Path>) -> RegistryResult<Self> {
162        lockfile_next::io::load_impl(path).await
163    }
164
165    /// Parse a lockfile from YAML content.
166    pub fn parse(content: &str) -> RegistryResult<Self> {
167        lockfile_next::parse::parse_lockfile_impl(content)
168    }
169
170    /// Save the lockfile to a path.
171    pub async fn save(&self, path: impl AsRef<Path>) -> RegistryResult<()> {
172        lockfile_next::io::save_impl(self, path).await
173    }
174
175    /// Convert to YAML string.
176    pub fn to_yaml(&self) -> RegistryResult<String> {
177        lockfile_next::format::to_yaml_impl(self)
178    }
179
180    /// Add or update a pack in the lockfile.
181    pub fn add_pack(&mut self, pack: LockedPack) {
182        lockfile_next::format::add_pack_impl(self, pack);
183    }
184
185    /// Remove a pack from the lockfile.
186    pub fn remove_pack(&mut self, name: &str) -> bool {
187        let len_before = self.packs.len();
188        self.packs.retain(|p| p.name != name);
189        self.packs.len() != len_before
190    }
191
192    /// Get a locked pack by name.
193    pub fn get_pack(&self, name: &str) -> Option<&LockedPack> {
194        self.packs.iter().find(|p| p.name == name)
195    }
196
197    /// Check if a pack is locked.
198    pub fn contains(&self, name: &str) -> bool {
199        self.packs.iter().any(|p| p.name == name)
200    }
201
202    /// Get all pack names.
203    pub fn pack_names(&self) -> Vec<&str> {
204        self.packs.iter().map(|p| p.name.as_str()).collect()
205    }
206}
207
208impl Default for Lockfile {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Generate a lockfile from pack references.
215pub async fn generate_lockfile(
216    references: &[String],
217    resolver: &PackResolver,
218) -> RegistryResult<Lockfile> {
219    lockfile_next::generate_lockfile_impl(references, resolver).await
220}
221
222/// Verify packs against a lockfile.
223pub async fn verify_lockfile(
224    lockfile: &Lockfile,
225    resolver: &PackResolver,
226) -> RegistryResult<VerifyLockResult> {
227    lockfile_next::digest::verify_lockfile_impl(lockfile, resolver).await
228}
229
230/// Check if lockfile is outdated (any pack has newer version available).
231pub async fn check_lockfile(
232    lockfile: &Lockfile,
233    resolver: &PackResolver,
234) -> RegistryResult<Vec<LockMismatch>> {
235    lockfile_next::digest::check_lockfile_impl(lockfile, resolver).await
236}
237
238/// Update a lockfile with latest versions.
239pub async fn update_lockfile(
240    lockfile: &mut Lockfile,
241    resolver: &PackResolver,
242) -> RegistryResult<Vec<String>> {
243    lockfile_next::digest::update_lockfile_impl(lockfile, resolver).await
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_lockfile_new() {
252        let lockfile = Lockfile::new();
253        assert_eq!(lockfile.version, LOCKFILE_VERSION);
254        assert!(lockfile.packs.is_empty());
255    }
256
257    #[test]
258    fn test_lockfile_parse() {
259        let yaml = r#"
260version: 2
261generated_at: "2026-01-29T10:00:00Z"
262generated_by: "assay-cli/2.10.1"
263packs:
264  - name: eu-ai-act-pro
265    version: "1.2.0"
266    digest: sha256:abc123def456
267    source: registry
268    registry_url: "https://registry.getassay.dev/v1"
269    signature:
270      algorithm: Ed25519
271      key_id: sha256:keyid123
272"#;
273
274        let lockfile = Lockfile::parse(yaml).unwrap();
275        assert_eq!(lockfile.version, 2);
276        assert_eq!(lockfile.packs.len(), 1);
277
278        let pack = &lockfile.packs[0];
279        assert_eq!(pack.name, "eu-ai-act-pro");
280        assert_eq!(pack.version, "1.2.0");
281        assert_eq!(pack.digest, "sha256:abc123def456");
282        assert_eq!(pack.source, LockSource::Registry);
283        assert!(pack.signature.is_some());
284    }
285
286    #[test]
287    fn test_lockfile_parse_unsupported_version() {
288        let yaml = r#"
289version: 99
290generated_at: "2026-01-29T10:00:00Z"
291generated_by: "future-cli/9.0.0"
292packs: []
293"#;
294
295        let result = Lockfile::parse(yaml);
296        assert!(matches!(result, Err(RegistryError::Lockfile { .. })));
297    }
298
299    #[test]
300    fn test_lockfile_add_pack() {
301        let mut lockfile = Lockfile::new();
302
303        let pack1 = LockedPack {
304            name: "pack-b".to_string(),
305            version: "1.0.0".to_string(),
306            digest: "sha256:bbb".to_string(),
307            source: LockSource::Registry,
308            registry_url: None,
309            byos_url: None,
310            signature: None,
311        };
312
313        let pack2 = LockedPack {
314            name: "pack-a".to_string(),
315            version: "1.0.0".to_string(),
316            digest: "sha256:aaa".to_string(),
317            source: LockSource::Registry,
318            registry_url: None,
319            byos_url: None,
320            signature: None,
321        };
322
323        lockfile.add_pack(pack1);
324        lockfile.add_pack(pack2);
325
326        // Should be sorted by name
327        assert_eq!(lockfile.packs[0].name, "pack-a");
328        assert_eq!(lockfile.packs[1].name, "pack-b");
329    }
330
331    #[test]
332    fn test_lockfile_add_pack_update() {
333        let mut lockfile = Lockfile::new();
334
335        let pack1 = LockedPack {
336            name: "my-pack".to_string(),
337            version: "1.0.0".to_string(),
338            digest: "sha256:old".to_string(),
339            source: LockSource::Registry,
340            registry_url: None,
341            byos_url: None,
342            signature: None,
343        };
344
345        let pack2 = LockedPack {
346            name: "my-pack".to_string(),
347            version: "1.1.0".to_string(),
348            digest: "sha256:new".to_string(),
349            source: LockSource::Registry,
350            registry_url: None,
351            byos_url: None,
352            signature: None,
353        };
354
355        lockfile.add_pack(pack1);
356        lockfile.add_pack(pack2);
357
358        // Should only have one entry (updated)
359        assert_eq!(lockfile.packs.len(), 1);
360        assert_eq!(lockfile.packs[0].version, "1.1.0");
361        assert_eq!(lockfile.packs[0].digest, "sha256:new");
362    }
363
364    #[test]
365    fn test_lockfile_remove_pack() {
366        let mut lockfile = Lockfile::new();
367
368        let pack = LockedPack {
369            name: "my-pack".to_string(),
370            version: "1.0.0".to_string(),
371            digest: "sha256:abc".to_string(),
372            source: LockSource::Registry,
373            registry_url: None,
374            byos_url: None,
375            signature: None,
376        };
377
378        lockfile.add_pack(pack);
379        assert!(lockfile.contains("my-pack"));
380
381        let removed = lockfile.remove_pack("my-pack");
382        assert!(removed);
383        assert!(!lockfile.contains("my-pack"));
384
385        let removed_again = lockfile.remove_pack("my-pack");
386        assert!(!removed_again);
387    }
388
389    #[test]
390    fn test_lockfile_get_pack() {
391        let mut lockfile = Lockfile::new();
392
393        let pack = LockedPack {
394            name: "my-pack".to_string(),
395            version: "1.0.0".to_string(),
396            digest: "sha256:abc".to_string(),
397            source: LockSource::Registry,
398            registry_url: None,
399            byos_url: None,
400            signature: None,
401        };
402
403        lockfile.add_pack(pack);
404
405        let found = lockfile.get_pack("my-pack");
406        assert!(found.is_some());
407        assert_eq!(found.unwrap().version, "1.0.0");
408
409        let not_found = lockfile.get_pack("other-pack");
410        assert!(not_found.is_none());
411    }
412
413    #[test]
414    fn test_lockfile_to_yaml() {
415        let mut lockfile = Lockfile::new();
416
417        let pack = LockedPack {
418            name: "my-pack".to_string(),
419            version: "1.0.0".to_string(),
420            digest: "sha256:abc123".to_string(),
421            source: LockSource::Registry,
422            registry_url: Some("https://registry.example.com/v1".to_string()),
423            byos_url: None,
424            signature: Some(LockSignature {
425                algorithm: "Ed25519".to_string(),
426                key_id: "sha256:key123".to_string(),
427            }),
428        };
429
430        lockfile.add_pack(pack);
431
432        let yaml = lockfile.to_yaml().unwrap();
433        assert!(yaml.contains("version: 2"));
434        assert!(yaml.contains("my-pack"));
435        assert!(yaml.contains("sha256:abc123"));
436        assert!(yaml.contains("Ed25519"));
437    }
438
439    #[test]
440    fn test_lock_source_serialize() {
441        let sources = vec![
442            (LockSource::Bundled, "bundled"),
443            (LockSource::Registry, "registry"),
444            (LockSource::Byos, "byos"),
445            (LockSource::Local, "local"),
446        ];
447
448        for (source, expected) in sources {
449            let yaml = serde_yaml::to_string(&source).unwrap();
450            assert!(yaml.contains(expected));
451        }
452    }
453
454    // ==================== Lockfile Semantics Tests (SPEC §8) ====================
455
456    #[test]
457    fn test_pack_not_in_lockfile() {
458        // SPEC §8.4: Pack not in lockfile should be detectable
459        let lockfile = Lockfile::new();
460
461        // contains() should return false for unknown pack
462        assert!(!lockfile.contains("unknown-pack"));
463
464        // get_pack() should return None
465        assert!(lockfile.get_pack("unknown-pack").is_none());
466
467        // pack_names() should be empty
468        assert!(lockfile.pack_names().is_empty());
469    }
470
471    #[test]
472    fn test_lockfile_v2_roundtrip() {
473        // SPEC §8.2: Lockfile should roundtrip through YAML serialization
474        let mut lockfile = Lockfile::new();
475
476        // Add multiple packs with all fields
477        lockfile.add_pack(LockedPack {
478            name: "pack-z".to_string(),
479            version: "2.0.0".to_string(),
480            digest: "sha256:zzz".to_string(),
481            source: LockSource::Registry,
482            registry_url: Some("https://registry.example.com/v1".to_string()),
483            byos_url: None,
484            signature: Some(LockSignature {
485                algorithm: "Ed25519".to_string(),
486                key_id: "sha256:keyzzz".to_string(),
487            }),
488        });
489
490        lockfile.add_pack(LockedPack {
491            name: "pack-a".to_string(),
492            version: "1.0.0".to_string(),
493            digest: "sha256:aaa".to_string(),
494            source: LockSource::Bundled,
495            registry_url: None,
496            byos_url: None,
497            signature: None,
498        });
499
500        lockfile.add_pack(LockedPack {
501            name: "pack-m".to_string(),
502            version: "1.5.0".to_string(),
503            digest: "sha256:mmm".to_string(),
504            source: LockSource::Byos,
505            registry_url: None,
506            byos_url: Some("s3://bucket/pack.yaml".to_string()),
507            signature: None,
508        });
509
510        // Serialize to YAML
511        let yaml = lockfile.to_yaml().unwrap();
512
513        // Parse back
514        let parsed = Lockfile::parse(&yaml).unwrap();
515
516        // Verify version preserved
517        assert_eq!(parsed.version, LOCKFILE_VERSION);
518
519        // Verify packs are sorted by name
520        assert_eq!(parsed.packs.len(), 3);
521        assert_eq!(parsed.packs[0].name, "pack-a");
522        assert_eq!(parsed.packs[1].name, "pack-m");
523        assert_eq!(parsed.packs[2].name, "pack-z");
524
525        // Verify all fields preserved
526        let pack_z = parsed.get_pack("pack-z").unwrap();
527        assert_eq!(pack_z.version, "2.0.0");
528        assert_eq!(pack_z.digest, "sha256:zzz");
529        assert_eq!(pack_z.source, LockSource::Registry);
530        assert!(pack_z.signature.is_some());
531
532        let pack_m = parsed.get_pack("pack-m").unwrap();
533        assert_eq!(pack_m.byos_url, Some("s3://bucket/pack.yaml".to_string()));
534    }
535
536    #[test]
537    fn test_lockfile_stable_ordering() {
538        // SPEC §8.2: Packs should be sorted by name for stable diffs
539        let mut lockfile = Lockfile::new();
540
541        // Add packs in random order
542        for name in ["zebra", "alpha", "middle", "beta"] {
543            lockfile.add_pack(LockedPack {
544                name: name.to_string(),
545                version: "1.0.0".to_string(),
546                digest: format!("sha256:{}", name),
547                source: LockSource::Registry,
548                registry_url: None,
549                byos_url: None,
550                signature: None,
551            });
552        }
553
554        // Verify sorted
555        let names: Vec<&str> = lockfile.pack_names().into_iter().collect();
556        assert_eq!(names, vec!["alpha", "beta", "middle", "zebra"]);
557    }
558
559    #[test]
560    fn test_lockfile_digest_mismatch_detection() {
561        // SPEC §8.4: Detect when digest differs from lockfile
562        let mut lockfile = Lockfile::new();
563
564        lockfile.add_pack(LockedPack {
565            name: "my-pack".to_string(),
566            version: "1.0.0".to_string(),
567            digest: "sha256:expected_digest_here".to_string(),
568            source: LockSource::Registry,
569            registry_url: None,
570            byos_url: None,
571            signature: None,
572        });
573
574        // Simulate checking against a different digest
575        let locked = lockfile.get_pack("my-pack").unwrap();
576        let actual_digest = "sha256:different_digest";
577
578        let mismatch = LockMismatch {
579            name: locked.name.clone(),
580            version: locked.version.clone(),
581            expected: locked.digest.clone(),
582            actual: actual_digest.to_string(),
583        };
584
585        // Verify mismatch is detectable
586        assert_ne!(mismatch.expected, mismatch.actual);
587        assert_eq!(mismatch.expected, "sha256:expected_digest_here");
588        assert_eq!(mismatch.actual, "sha256:different_digest");
589    }
590
591    #[test]
592    fn test_lockfile_version_1_rejected() {
593        // SPEC §8.2: Old lockfile versions should be handled
594        // Version 1 is older than current (2), but should still parse
595        let yaml_v1 = r#"
596version: 1
597generated_at: "2025-01-01T00:00:00Z"
598generated_by: "assay-cli/1.0.0"
599packs: []
600"#;
601
602        let result = Lockfile::parse(yaml_v1);
603        // Version 1 is supported (less than current)
604        assert!(result.is_ok());
605    }
606
607    #[test]
608    fn test_lockfile_future_version_rejected() {
609        // SPEC §8.2: Future lockfile versions should be rejected
610        let yaml_future = r#"
611version: 99
612generated_at: "2030-01-01T00:00:00Z"
613generated_by: "future-cli/99.0.0"
614packs: []
615"#;
616
617        let result = Lockfile::parse(yaml_future);
618        assert!(
619            matches!(result, Err(RegistryError::Lockfile { .. })),
620            "Should reject future lockfile version"
621        );
622    }
623
624    #[test]
625    fn test_lockfile_signature_fields() {
626        // SPEC §8.2: Signature fields in lockfile
627        let yaml = r#"
628version: 2
629generated_at: "2026-01-29T10:00:00Z"
630generated_by: "assay-cli/2.10.0"
631packs:
632  - name: signed-pack
633    version: "1.0.0"
634    digest: sha256:abc123
635    source: registry
636    signature:
637      algorithm: Ed25519
638      key_id: sha256:keyid123
639"#;
640
641        let lockfile = Lockfile::parse(yaml).unwrap();
642        let pack = lockfile.get_pack("signed-pack").unwrap();
643
644        assert!(pack.signature.is_some());
645        let sig = pack.signature.as_ref().unwrap();
646        assert_eq!(sig.algorithm, "Ed25519");
647        assert_eq!(sig.key_id, "sha256:keyid123");
648    }
649}