1use 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
36pub const LOCKFILE_NAME: &str = "assay.packs.lock";
38
39pub const LOCKFILE_VERSION: u8 = 2;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Lockfile {
45 pub version: u8,
47
48 pub generated_at: DateTime<Utc>,
50
51 pub generated_by: String,
53
54 #[serde(default)]
56 pub packs: Vec<LockedPack>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct LockedPack {
62 pub name: String,
64
65 pub version: String,
67
68 pub digest: String,
70
71 pub source: LockSource,
73
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub registry_url: Option<String>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub byos_url: Option<String>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub signature: Option<LockSignature>,
85}
86
87#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "lowercase")]
90pub enum LockSource {
91 Bundled,
93
94 Registry,
96
97 Byos,
99
100 Local,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct LockSignature {
107 pub algorithm: String,
109
110 pub key_id: String,
112}
113
114#[derive(Debug, Clone)]
116pub struct VerifyLockResult {
117 pub all_match: bool,
119
120 pub matched: Vec<String>,
122
123 pub mismatched: Vec<LockMismatch>,
125
126 pub missing: Vec<String>,
128
129 pub extra: Vec<String>,
131}
132
133#[derive(Debug, Clone)]
135pub struct LockMismatch {
136 pub name: String,
138
139 pub version: String,
141
142 pub expected: String,
144
145 pub actual: String,
147}
148
149impl Lockfile {
150 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 pub async fn load(path: impl AsRef<Path>) -> RegistryResult<Self> {
162 lockfile_next::io::load_impl(path).await
163 }
164
165 pub fn parse(content: &str) -> RegistryResult<Self> {
167 lockfile_next::parse::parse_lockfile_impl(content)
168 }
169
170 pub async fn save(&self, path: impl AsRef<Path>) -> RegistryResult<()> {
172 lockfile_next::io::save_impl(self, path).await
173 }
174
175 pub fn to_yaml(&self) -> RegistryResult<String> {
177 lockfile_next::format::to_yaml_impl(self)
178 }
179
180 pub fn add_pack(&mut self, pack: LockedPack) {
182 lockfile_next::format::add_pack_impl(self, pack);
183 }
184
185 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 pub fn get_pack(&self, name: &str) -> Option<&LockedPack> {
194 self.packs.iter().find(|p| p.name == name)
195 }
196
197 pub fn contains(&self, name: &str) -> bool {
199 self.packs.iter().any(|p| p.name == name)
200 }
201
202 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
214pub async fn generate_lockfile(
216 references: &[String],
217 resolver: &PackResolver,
218) -> RegistryResult<Lockfile> {
219 lockfile_next::generate_lockfile_impl(references, resolver).await
220}
221
222pub 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
230pub 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
238pub 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 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 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 #[test]
457 fn test_pack_not_in_lockfile() {
458 let lockfile = Lockfile::new();
460
461 assert!(!lockfile.contains("unknown-pack"));
463
464 assert!(lockfile.get_pack("unknown-pack").is_none());
466
467 assert!(lockfile.pack_names().is_empty());
469 }
470
471 #[test]
472 fn test_lockfile_v2_roundtrip() {
473 let mut lockfile = Lockfile::new();
475
476 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 let yaml = lockfile.to_yaml().unwrap();
512
513 let parsed = Lockfile::parse(&yaml).unwrap();
515
516 assert_eq!(parsed.version, LOCKFILE_VERSION);
518
519 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 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 let mut lockfile = Lockfile::new();
540
541 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 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 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 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 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 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 assert!(result.is_ok());
605 }
606
607 #[test]
608 fn test_lockfile_future_version_rejected() {
609 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 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}