-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlockfile.rs
More file actions
1611 lines (1440 loc) · 55.5 KB
/
Copy pathlockfile.rs
File metadata and controls
1611 lines (1440 loc) · 55.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Lockfile data structures and I/O operations
//!
//! This module provides complete lockfile support for DevContainer configurations,
//! following the DevContainer specification. It implements data structures for
//! representing feature lock entries and provides functions for reading, writing,
//! and merging lockfiles.
//!
//! ## Overview
//!
//! Lockfiles track resolved feature versions and their integrity information,
//! enabling reproducible container builds and version management.
//!
//! ## Data Structures
//!
//! - [`Lockfile`] - Top-level lockfile structure containing feature entries
//! - [`LockfileFeature`] - Individual feature lock entry with version and integrity info
//!
//! ## Path Derivation
//!
//! Lockfile names follow a convention based on the config file basename:
//! - Config starting with `.` → `.devcontainer-lock.json`
//! - Otherwise → `devcontainer-lock.json`
//! - Location: Same directory as config file
//!
//! ## Operations
//!
//! - [`get_lockfile_path`] - Derive lockfile path from config path
//! - [`read_lockfile`] - Read and parse lockfile (returns None if not found)
//! - [`write_lockfile`] - Write lockfile with atomic operation
//! - [`merge_lockfile_features`] - Merge two lockfiles with conflict resolution
//!
//! ## Examples
//!
//! ```rust
//! use deacon_core::lockfile::{Lockfile, LockfileFeature, get_lockfile_path};
//! use std::path::Path;
//! use std::collections::HashMap;
//!
//! // Determine lockfile path
//! let config_path = Path::new(".devcontainer/devcontainer.json");
//! let lockfile_path = get_lockfile_path(config_path);
//! assert_eq!(lockfile_path, Path::new(".devcontainer/devcontainer-lock.json"));
//!
//! // Create a new lockfile
//! let mut lockfile = Lockfile {
//! features: HashMap::new(),
//! };
//!
//! lockfile.features.insert(
//! "ghcr.io/devcontainers/features/node".to_string(),
//! LockfileFeature {
//! version: "1.2.3".to_string(),
//! resolved: "ghcr.io/devcontainers/features/node@sha256:abc123".to_string(),
//! integrity: "sha256:abc123".to_string(),
//! depends_on: None,
//! },
//! );
//! ```
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Lockfile structure per DevContainer specification
///
/// Contains a map of feature identifiers to their lock entries.
/// The map keys are typically OCI references or feature identifiers.
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::{Lockfile, LockfileFeature};
/// use std::collections::HashMap;
///
/// let mut lockfile = Lockfile {
/// features: HashMap::new(),
/// };
///
/// lockfile.features.insert(
/// "ghcr.io/devcontainers/features/docker".to_string(),
/// LockfileFeature {
/// version: "2.0.0".to_string(),
/// resolved: "ghcr.io/devcontainers/features/docker@sha256:def456".to_string(),
/// integrity: "sha256:def456".to_string(),
/// depends_on: Some(vec!["ghcr.io/devcontainers/features/common".to_string()]),
/// },
/// );
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Lockfile {
/// Map of feature identifiers to their lock entries
pub features: HashMap<String, LockfileFeature>,
}
/// Individual feature lock entry
///
/// Contains version information and integrity data for a single feature.
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::LockfileFeature;
///
/// let feature = LockfileFeature {
/// version: "1.0.0".to_string(),
/// resolved: "ghcr.io/devcontainers/features/node@sha256:abc123".to_string(),
/// integrity: "sha256:abc123".to_string(),
/// depends_on: None,
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockfileFeature {
/// Semantic version (e.g., "2.11.1")
pub version: String,
/// Full OCI reference with digest (e.g., "ghcr.io/owner/feature@sha256:...")
pub resolved: String,
/// SHA256 digest for integrity checking (e.g., "sha256:...")
pub integrity: String,
/// Optional feature dependencies. Spec emits this field as `dependsOn`
/// (camelCase) — see upstream `generateLockfile` in
/// `devcontainers/cli` `src/spec-configuration/lockfile.ts`.
#[serde(rename = "dependsOn", skip_serializing_if = "Option::is_none", default)]
pub depends_on: Option<Vec<String>>,
}
impl LockfileFeature {
/// Construct a lockfile entry in the upstream canonical form.
///
/// Mirrors `generateLockfile` in `devcontainers/cli`
/// `src/spec-configuration/lockfile.ts`:
/// resolved = `{registry}/{repository}@{digest}`
/// integrity = `{digest}`
/// The `digest` argument MUST be in `sha256:<64-hex>` form (the manifest
/// digest returned by the OCI fetcher).
///
/// `depends_on` should be an alphabetically-sorted vec of feature IDs, or
/// `None` for features with no dependencies.
pub fn from_resolved(
registry: &str,
repository: &str,
digest: &str,
version: String,
depends_on: Option<Vec<String>>,
) -> Self {
Self {
version,
resolved: format!("{}/{}@{}", registry, repository, digest),
integrity: digest.to_string(),
depends_on,
}
}
}
/// Get lockfile path adjacent to config file
///
/// Implements the lockfile naming convention:
/// - If config basename starts with `.` → `.devcontainer-lock.json`
/// - Otherwise → `devcontainer-lock.json`
/// - Location: Same directory as config file
///
/// # Arguments
///
/// * `config_path` - Path to the DevContainer configuration file
///
/// # Returns
///
/// Path to the lockfile in the same directory as the config file
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::get_lockfile_path;
/// use std::path::Path;
///
/// // Config with dot prefix
/// let config = Path::new(".devcontainer/devcontainer.json");
/// let lockfile = get_lockfile_path(config);
/// assert_eq!(lockfile, Path::new(".devcontainer/devcontainer-lock.json"));
///
/// // Hidden config file
/// let config = Path::new(".devcontainer/.devcontainer.json");
/// let lockfile = get_lockfile_path(config);
/// assert_eq!(lockfile, Path::new(".devcontainer/.devcontainer-lock.json"));
/// ```
pub fn get_lockfile_path(config_path: &Path) -> PathBuf {
let config_dir = config_path.parent().unwrap_or(Path::new("."));
let config_basename = config_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("devcontainer.json");
let lockfile_name = if config_basename.starts_with('.') {
".devcontainer-lock.json"
} else {
"devcontainer-lock.json"
};
config_dir.join(lockfile_name)
}
/// Read lockfile from disk
///
/// Reads and parses a lockfile from the specified path. Returns `None` if the
/// file doesn't exist (not an error condition). Invalid JSON or I/O errors
/// are returned as errors.
///
/// # Arguments
///
/// * `path` - Path to the lockfile
///
/// # Returns
///
/// - `Ok(Some(Lockfile))` if file exists and is valid
/// - `Ok(None)` if file doesn't exist
/// - `Err(...)` for I/O errors or invalid JSON
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::read_lockfile;
/// use std::path::Path;
///
/// // Non-existent file returns None (not an error)
/// let result = read_lockfile(Path::new("/tmp/nonexistent-lockfile.json")).unwrap();
/// assert!(result.is_none());
/// ```
pub fn read_lockfile(path: &Path) -> Result<Option<Lockfile>> {
// Check if file exists
if !path.exists() {
return Ok(None);
}
// Read file contents
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read lockfile from {}", path.display()))?;
// Parse JSON
let lockfile: Lockfile = serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse lockfile from {}", path.display()))?;
// Validate lockfile
validate_lockfile(&lockfile)
.with_context(|| format!("Lockfile validation failed for {}", path.display()))?;
Ok(Some(lockfile))
}
/// Write lockfile to disk
///
/// Writes a lockfile to the specified path with atomic operation (write to temp,
/// then rename). Creates parent directories if needed. Formats JSON with 2-space
/// indentation for readability.
///
/// # Arguments
///
/// * `path` - Path to write the lockfile
/// * `lockfile` - Lockfile data to write
/// * `force_init` - If true, always write; if false, may skip in certain conditions
///
/// # Returns
///
/// Result indicating success or failure
///
/// # Examples
///
/// ```rust,no_run
/// use deacon_core::lockfile::{Lockfile, write_lockfile};
/// use std::collections::HashMap;
/// use std::path::Path;
///
/// let lockfile = Lockfile {
/// features: HashMap::new(),
/// };
///
/// write_lockfile(Path::new("/tmp/test-lock.json"), &lockfile, false).unwrap();
/// ```
pub fn write_lockfile(path: &Path, lockfile: &Lockfile, force_init: bool) -> Result<()> {
// Check if file exists and force_init is false
if path.exists() && !force_init {
anyhow::bail!(
"Lockfile already exists at {}. Use force_init=true to overwrite.",
path.display()
);
}
// Validate lockfile before writing
validate_lockfile(lockfile).context("Lockfile validation failed before write")?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
// Convert to serde_json::Value for deterministic ordering
let mut value =
serde_json::to_value(lockfile).context("Failed to convert lockfile to JSON value")?;
// Sort all object keys recursively for stable JSON output
sort_json_object(&mut value);
// Serialize with pretty printing (2-space indentation) and a trailing
// newline to match upstream `devcontainers/cli`'s `writeLockfile` output
// (`JSON.stringify(..., 2) + '\n'`). Byte-identical output keeps the
// `--frozen-lockfile` content comparison stable across implementations.
let mut json =
serde_json::to_string_pretty(&value).context("Failed to serialize lockfile to JSON")?;
json.push('\n');
// Atomic write: write to temp file in same directory, then rename
// Using same directory ensures same filesystem for atomic rename on all platforms
let temp_path = if let Some(parent) = path.parent() {
parent.join(format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("lockfile")
))
} else {
PathBuf::from(format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("lockfile")
))
};
fs::write(&temp_path, json.as_bytes()).with_context(|| {
format!(
"Failed to write temporary lockfile to {}",
temp_path.display()
)
})?;
// On Windows, remove destination file if it exists before rename
#[cfg(windows)]
if path.exists() {
fs::remove_file(path)
.with_context(|| format!("Failed to remove existing lockfile at {}", path.display()))?;
}
fs::rename(&temp_path, path)
.with_context(|| format!("Failed to rename temporary lockfile to {}", path.display()))?;
Ok(())
}
/// Merge two lockfiles
///
/// Combines feature entries from two lockfiles. When a feature exists in both
/// lockfiles, the entry from `new` takes precedence. Features only in `existing`
/// are preserved.
///
/// # Arguments
///
/// * `existing` - Current lockfile
/// * `new` - New lockfile with updates
///
/// # Returns
///
/// Merged lockfile combining both inputs
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::{Lockfile, LockfileFeature, merge_lockfile_features};
/// use std::collections::HashMap;
///
/// let mut existing = Lockfile { features: HashMap::new() };
/// existing.features.insert(
/// "feature-a".to_string(),
/// LockfileFeature {
/// version: "1.0.0".to_string(),
/// resolved: "registry/feature-a@sha256:old".to_string(),
/// integrity: "sha256:old".to_string(),
/// depends_on: None,
/// },
/// );
///
/// let mut new = Lockfile { features: HashMap::new() };
/// new.features.insert(
/// "feature-a".to_string(),
/// LockfileFeature {
/// version: "2.0.0".to_string(),
/// resolved: "registry/feature-a@sha256:new".to_string(),
/// integrity: "sha256:new".to_string(),
/// depends_on: None,
/// },
/// );
///
/// let merged = merge_lockfile_features(&existing, &new);
/// assert_eq!(merged.features.get("feature-a").unwrap().version, "2.0.0");
/// ```
pub fn merge_lockfile_features(existing: &Lockfile, new: &Lockfile) -> Lockfile {
let mut merged_features = existing.features.clone();
// Overlay new features (new wins on conflicts)
for (feature_id, feature_entry) in &new.features {
merged_features.insert(feature_id.clone(), feature_entry.clone());
}
Lockfile {
features: merged_features,
}
}
/// Validate lockfile structure and contents
///
/// Checks that all fields contain valid data:
/// - Version fields are valid semver
/// - Resolved fields are valid OCI references
/// - Integrity fields are valid SHA256 digests
/// - Dependency references exist in the lockfile
/// - No circular dependencies
fn validate_lockfile(lockfile: &Lockfile) -> Result<()> {
for (feature_id, feature) in &lockfile.features {
// Validate version is valid semver
validate_semver(&feature.version)
.with_context(|| format!("Invalid version field for feature '{}'", feature_id))?;
// Validate resolved is a valid OCI reference
validate_oci_reference(&feature.resolved)
.with_context(|| format!("Invalid resolved field for feature '{}'", feature_id))?;
// Validate integrity is a valid SHA256 digest
validate_sha256_digest(&feature.integrity)
.with_context(|| format!("Invalid integrity field for feature '{}'", feature_id))?;
// Validate dependencies exist in lockfile
if let Some(deps) = &feature.depends_on {
for dep in deps {
if !lockfile.features.contains_key(dep) {
anyhow::bail!(
"Feature '{}' has dependency '{}' in depends_on field which is not present in the lockfile",
feature_id,
dep
);
}
}
}
}
// Check for circular dependencies
detect_dependency_cycles(lockfile)?;
Ok(())
}
/// Detect circular dependencies in the lockfile
fn detect_dependency_cycles(lockfile: &Lockfile) -> Result<()> {
use std::collections::HashSet;
fn visit(
feature_id: &str,
lockfile: &Lockfile,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
) -> Result<()> {
visited.insert(feature_id.to_string());
rec_stack.insert(feature_id.to_string());
path.push(feature_id.to_string());
if let Some(feature) = lockfile.features.get(feature_id) {
if let Some(deps) = &feature.depends_on {
for dep in deps {
if !visited.contains(dep) {
visit(dep, lockfile, visited, rec_stack, path)?;
} else if rec_stack.contains(dep) {
// Found a cycle
path.push(dep.to_string());
let cycle_path = path.join(" -> ");
anyhow::bail!(
"Circular dependency detected in depends_on fields: {}",
cycle_path
);
}
}
}
}
path.pop();
rec_stack.remove(feature_id);
Ok(())
}
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut path = Vec::new();
for feature_id in lockfile.features.keys() {
if !visited.contains(feature_id) {
visit(
feature_id,
lockfile,
&mut visited,
&mut rec_stack,
&mut path,
)?;
}
}
Ok(())
}
/// Recursively sort all keys in a JSON object for deterministic output
fn sort_json_object(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
// Convert to BTreeMap for sorted keys
let sorted: std::collections::BTreeMap<_, _> = map.iter().collect();
*map = sorted
.into_iter()
.map(|(k, v)| {
let mut v = v.clone();
sort_json_object(&mut v);
(k.clone(), v)
})
.collect();
}
serde_json::Value::Array(arr) => {
for item in arr {
sort_json_object(item);
}
}
_ => {}
}
}
/// Validate semantic version format
fn validate_semver(version: &str) -> Result<()> {
// Use semver crate for proper validation
use semver::Version;
Version::parse(version).with_context(|| {
format!(
"Invalid semantic version '{}': must be in format X.Y.Z (e.g., '1.2.3')",
version
)
})?;
Ok(())
}
/// Validate OCI reference format
///
/// Basic validation that the reference contains required components
fn validate_oci_reference(reference: &str) -> Result<()> {
// Must contain @ for digest-based reference
if !reference.contains('@') {
anyhow::bail!(
"OCI reference '{}' must contain '@' separator with digest (expected format: 'registry/path@sha256:...')",
reference
);
}
// Must contain sha256: in the digest part
if !reference.contains("sha256:") {
anyhow::bail!(
"OCI reference '{}' must contain 'sha256:' digest (expected format: 'registry/path@sha256:...')",
reference
);
}
Ok(())
}
/// Validate SHA256 digest format
fn validate_sha256_digest(digest: &str) -> Result<()> {
// Must start with sha256:
if !digest.starts_with("sha256:") {
anyhow::bail!(
"Digest '{}' must start with 'sha256:' (expected format: 'sha256:<64-hex-chars>')",
digest
);
}
// Extract hash part after sha256:
let hash = digest.strip_prefix("sha256:").unwrap();
// Hash should be 64 hex characters
if hash.len() != 64 {
anyhow::bail!(
"SHA256 hash in '{}' must be exactly 64 characters, got {} (expected format: 'sha256:<64-hex-chars>')",
digest,
hash.len()
);
}
// All characters should be valid hex
if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!(
"SHA256 hash in '{}' must contain only hexadecimal characters (0-9, a-f, A-F)",
digest
);
}
Ok(())
}
/// Result of validating a lockfile against configuration.
///
/// Describes whether the lockfile matches the configuration's declared features,
/// or details of any mismatch.
#[derive(Debug, Clone, PartialEq)]
pub enum LockfileValidationResult {
/// Lockfile matches configuration - all declared features are locked
/// and no extra features exist in the lockfile.
Matched,
/// Lockfile is missing entirely when validation was requested.
Missing {
/// Expected lockfile path
expected_path: PathBuf,
},
/// Features declared in config but missing from lockfile.
MissingFromLockfile {
/// Feature IDs that are in config but not in lockfile
features: Vec<String>,
},
/// Features in lockfile but not declared in config.
ExtraInLockfile {
/// Feature IDs that are in lockfile but not in config
features: Vec<String>,
},
/// Both missing and extra features found.
Mismatch {
/// Feature IDs that are in config but not in lockfile
missing_from_lockfile: Vec<String>,
/// Feature IDs that are in lockfile but not in config
extra_in_lockfile: Vec<String>,
},
}
impl LockfileValidationResult {
/// Returns true if the validation result indicates a match.
pub fn is_matched(&self) -> bool {
matches!(self, LockfileValidationResult::Matched)
}
/// Format the validation result as an error message.
///
/// Returns a user-friendly error message describing the mismatch,
/// including actionable guidance on how to resolve the issue. The leading
/// summary line mirrors the canonical upstream `devcontainers/cli` strings
/// (`"Lockfile does not exist."` / `"Lockfile does not match."`) so
/// existing CI scripts that match on those messages continue to work.
pub fn format_error(&self) -> String {
match self {
LockfileValidationResult::Matched => "Lockfile validation passed".to_string(),
LockfileValidationResult::Missing { expected_path } => {
format!(
"Lockfile does not exist.\nExpected at '{}'.\n\
Run without --frozen-lockfile to generate a lockfile, or \
generate one with `deacon upgrade`.",
expected_path.display()
)
}
LockfileValidationResult::MissingFromLockfile { features } => {
format!(
"Lockfile does not match.\nFeatures declared in config but missing from lockfile:\n \
- {}\n\
Run without --frozen-lockfile to update the lockfile, or run `deacon upgrade`.",
features.join("\n - ")
)
}
LockfileValidationResult::ExtraInLockfile { features } => {
format!(
"Lockfile does not match.\nFeatures in lockfile but not declared in config:\n \
- {}\n\
Update the lockfile to remove stale entries (e.g. via `deacon upgrade`), \
or add these features to your config.",
features.join("\n - ")
)
}
LockfileValidationResult::Mismatch {
missing_from_lockfile,
extra_in_lockfile,
} => {
format!(
"Lockfile does not match.\n\
Features declared in config but missing from lockfile:\n - {}\n\
Features in lockfile but not declared in config:\n - {}\n\
Run without --frozen-lockfile to update the lockfile, or run `deacon upgrade`.",
missing_from_lockfile.join("\n - "),
extra_in_lockfile.join("\n - ")
)
}
}
}
}
/// Extract feature IDs from a DevContainer config features object.
///
/// The features field in DevContainerConfig is a serde_json::Value that
/// should be an object with feature IDs as keys.
///
/// # Arguments
///
/// * `features` - The features field from DevContainerConfig
///
/// # Returns
///
/// A sorted vector of feature ID strings, or an empty vector if features
/// is not an object or is empty.
pub fn extract_feature_ids_from_config(features: &serde_json::Value) -> Vec<String> {
match features.as_object() {
Some(obj) => {
let mut ids: Vec<String> = obj.keys().cloned().collect();
ids.sort();
ids
}
None => Vec::new(),
}
}
/// Validate a lockfile against the features declared in configuration.
///
/// This function compares the features declared in a DevContainer configuration
/// against the features locked in a lockfile. It is used to implement frozen
/// lockfile validation where builds should fail if the lockfile doesn't match.
///
/// # Arguments
///
/// * `lockfile` - The lockfile to validate (can be None if missing)
/// * `config_features` - The features field from DevContainerConfig
/// * `lockfile_path` - Path where the lockfile was expected (for error messages)
///
/// # Returns
///
/// A `LockfileValidationResult` indicating whether the lockfile matches,
/// or details about the mismatch.
///
/// # Examples
///
/// ```rust
/// use deacon_core::lockfile::{Lockfile, LockfileFeature, validate_lockfile_against_config, LockfileValidationResult};
/// use std::collections::HashMap;
/// use std::path::Path;
///
/// // Create a lockfile with one feature
/// let mut features = HashMap::new();
/// features.insert(
/// "ghcr.io/devcontainers/features/node:1".to_string(),
/// LockfileFeature {
/// version: "1.0.0".to_string(),
/// resolved: "ghcr.io/devcontainers/features/node@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd".to_string(),
/// integrity: "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd".to_string(),
/// depends_on: None,
/// },
/// );
/// let lockfile = Lockfile { features };
///
/// // Config with matching feature
/// let config_features = serde_json::json!({
/// "ghcr.io/devcontainers/features/node:1": {}
/// });
///
/// let result = validate_lockfile_against_config(
/// Some(&lockfile),
/// &config_features,
/// Path::new("devcontainer-lock.json"),
/// );
///
/// assert!(result.is_matched());
/// ```
pub fn validate_lockfile_against_config(
lockfile: Option<&Lockfile>,
config_features: &serde_json::Value,
lockfile_path: &Path,
) -> LockfileValidationResult {
// If lockfile is missing, return Missing result
let lockfile = match lockfile {
Some(lf) => lf,
None => {
return LockfileValidationResult::Missing {
expected_path: lockfile_path.to_path_buf(),
};
}
};
// Extract feature IDs from config
let config_feature_ids = extract_feature_ids_from_config(config_features);
// Get feature IDs from lockfile
let mut lockfile_feature_ids: Vec<String> = lockfile.features.keys().cloned().collect();
lockfile_feature_ids.sort();
// Find features in config but not in lockfile
let missing_from_lockfile: Vec<String> = config_feature_ids
.iter()
.filter(|id| !lockfile.features.contains_key(*id))
.cloned()
.collect();
// Find features in lockfile but not in config
let config_feature_set: std::collections::HashSet<&String> =
config_feature_ids.iter().collect();
let extra_in_lockfile: Vec<String> = lockfile_feature_ids
.iter()
.filter(|id| !config_feature_set.contains(id))
.cloned()
.collect();
// Determine result based on findings
match (
missing_from_lockfile.is_empty(),
extra_in_lockfile.is_empty(),
) {
(true, true) => LockfileValidationResult::Matched,
(false, true) => LockfileValidationResult::MissingFromLockfile {
features: missing_from_lockfile,
},
(true, false) => LockfileValidationResult::ExtraInLockfile {
features: extra_in_lockfile,
},
(false, false) => LockfileValidationResult::Mismatch {
missing_from_lockfile,
extra_in_lockfile,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_get_lockfile_path_normal_config() {
let config = Path::new(".devcontainer/devcontainer.json");
let lockfile = get_lockfile_path(config);
assert_eq!(lockfile, Path::new(".devcontainer/devcontainer-lock.json"));
}
#[test]
fn test_get_lockfile_path_hidden_config() {
let config = Path::new(".devcontainer/.devcontainer.json");
let lockfile = get_lockfile_path(config);
assert_eq!(lockfile, Path::new(".devcontainer/.devcontainer-lock.json"));
}
#[test]
fn test_get_lockfile_path_root_directory() {
let config = Path::new("devcontainer.json");
let lockfile = get_lockfile_path(config);
assert_eq!(lockfile, Path::new("devcontainer-lock.json"));
}
#[test]
fn test_get_lockfile_path_hidden_root() {
let config = Path::new(".devcontainer.json");
let lockfile = get_lockfile_path(config);
assert_eq!(lockfile, Path::new(".devcontainer-lock.json"));
}
#[test]
fn test_lockfile_serialization_roundtrip() {
let mut lockfile = Lockfile {
features: HashMap::new(),
};
lockfile.features.insert(
"ghcr.io/devcontainers/features/node".to_string(),
LockfileFeature {
version: "1.2.3".to_string(),
resolved: "ghcr.io/devcontainers/features/node@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd".to_string(),
integrity: "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd".to_string(),
depends_on: None,
},
);
// Serialize
let json = serde_json::to_string_pretty(&lockfile).unwrap();
// Deserialize
let parsed: Lockfile = serde_json::from_str(&json).unwrap();
// Verify equality
assert_eq!(lockfile, parsed);
}
#[test]
fn test_lockfile_with_dependencies() {
let mut lockfile = Lockfile {
features: HashMap::new(),
};
lockfile.features.insert(
"feature-a".to_string(),
LockfileFeature {
version: "1.0.0".to_string(),
resolved: "registry/feature-a@sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
integrity: "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
depends_on: Some(vec!["feature-b".to_string()]),
},
);
lockfile.features.insert(
"feature-b".to_string(),
LockfileFeature {
version: "2.0.0".to_string(),
resolved: "registry/feature-b@sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
integrity: "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
depends_on: None,
},
);
// Validation should pass
validate_lockfile(&lockfile).unwrap();
}
#[test]
fn test_merge_lockfile_features_basic() {
let mut existing = Lockfile {
features: HashMap::new(),
};
existing.features.insert(
"feature-a".to_string(),
LockfileFeature {
version: "1.0.0".to_string(),
resolved: "registry/feature-a@sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
integrity: "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
depends_on: None,
},
);
let mut new = Lockfile {
features: HashMap::new(),
};
new.features.insert(
"feature-b".to_string(),
LockfileFeature {
version: "2.0.0".to_string(),
resolved: "registry/feature-b@sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
integrity: "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
depends_on: None,
},
);
let merged = merge_lockfile_features(&existing, &new);
assert_eq!(merged.features.len(), 2);
assert!(merged.features.contains_key("feature-a"));
assert!(merged.features.contains_key("feature-b"));
}
#[test]
fn test_merge_lockfile_features_conflict() {
let mut existing = Lockfile {
features: HashMap::new(),
};
existing.features.insert(
"feature-a".to_string(),
LockfileFeature {
version: "1.0.0".to_string(),
resolved: "registry/feature-a@sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
integrity: "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(),
depends_on: None,
},
);
let mut new = Lockfile {
features: HashMap::new(),
};
new.features.insert(
"feature-a".to_string(),
LockfileFeature {
version: "2.0.0".to_string(),
resolved: "registry/feature-a@sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
integrity: "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_string(),
depends_on: None,
},
);
let merged = merge_lockfile_features(&existing, &new);
// New should win
assert_eq!(merged.features.len(), 1);
assert_eq!(merged.features.get("feature-a").unwrap().version, "2.0.0");
}
#[test]
fn test_read_nonexistent_lockfile() {
let temp_dir = TempDir::new().unwrap();
let lockfile_path = temp_dir.path().join("nonexistent.json");
let result = read_lockfile(&lockfile_path).unwrap();
assert!(result.is_none());
}
#[test]