1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{BvError, Result};
7
8pub type BinaryIndex = BTreeMap<String, String>;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ReferenceDataPin {
13 pub id: String,
14 pub version: String,
15 pub sha256: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct LockfileEntry {
26 pub tool_id: String,
27 #[serde(default, skip_serializing_if = "String::is_empty")]
29 pub declared_version_req: String,
30 pub version: String,
32 pub image_reference: String,
34 pub image_digest: String,
36 #[serde(default, skip_serializing_if = "String::is_empty")]
38 pub manifest_sha256: String,
39 pub image_size_bytes: Option<u64>,
40 pub resolved_at: DateTime<Utc>,
41 #[serde(default)]
42 pub reference_data_pins: BTreeMap<String, ReferenceDataPin>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub binaries: Vec<String>,
46}
47
48impl LockfileEntry {
49 pub fn is_equivalent(&self, other: &Self) -> bool {
52 self.tool_id == other.tool_id
53 && self.version == other.version
54 && self.image_digest == other.image_digest
55 && (self.manifest_sha256.is_empty()
56 || other.manifest_sha256.is_empty()
57 || self.manifest_sha256 == other.manifest_sha256)
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct LockfileMetadata {
64 pub bv_version: String,
65 pub generated_at: DateTime<Utc>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub hardware_summary: Option<String>,
68}
69
70impl Default for LockfileMetadata {
71 fn default() -> Self {
72 Self {
73 bv_version: env!("CARGO_PKG_VERSION").to_string(),
74 generated_at: Utc::now(),
75 hardware_summary: None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Lockfile {
87 pub version: u32,
89 #[serde(default)]
90 pub metadata: LockfileMetadata,
91 #[serde(default)]
92 pub tools: BTreeMap<String, LockfileEntry>,
93 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96 pub binary_index: BinaryIndex,
97}
98
99impl Lockfile {
100 pub fn new() -> Self {
101 Self {
102 version: 1,
103 metadata: LockfileMetadata::default(),
104 tools: BTreeMap::new(),
105 binary_index: BTreeMap::new(),
106 }
107 }
108
109 pub fn from_toml_str(s: &str) -> Result<Self> {
110 toml::from_str(s).map_err(|e| BvError::LockfileParse(e.to_string()))
111 }
112
113 pub fn to_toml_string(&self) -> Result<String> {
114 toml::to_string_pretty(self).map_err(|e| BvError::LockfileParse(e.to_string()))
115 }
116
117 pub fn rebuild_binary_index(
122 &mut self,
123 overrides: &BTreeMap<String, String>,
124 ) -> std::result::Result<(), String> {
125 let mut index: BinaryIndex = BTreeMap::new();
126 let mut collisions: Vec<String> = Vec::new();
127
128 let mut sorted: Vec<_> = self.tools.iter().collect();
129 sorted.sort_by_key(|(id, _)| id.as_str());
130
131 for (tool_id, entry) in &sorted {
132 for binary in &entry.binaries {
133 if let Some(winner) = overrides.get(binary) {
134 index.insert(binary.clone(), winner.clone());
135 } else if let Some(existing) = index.insert(binary.clone(), tool_id.to_string())
136 && existing != tool_id.as_str()
137 {
138 collisions.push(format!(
139 "'{binary}' exposed by both '{existing}' and '{tool_id}'"
140 ));
141 index.insert(binary.clone(), existing);
142 }
143 }
144 }
145
146 if !collisions.is_empty() {
147 return Err(collisions.join(", "));
148 }
149 self.binary_index = index;
150 Ok(())
151 }
152
153 pub fn is_equivalent_to(&self, other: &Self) -> bool {
156 if self.tools.len() != other.tools.len() {
157 return false;
158 }
159 for (id, entry) in &self.tools {
160 match other.tools.get(id) {
161 Some(other_entry) => {
162 if !entry.is_equivalent(other_entry) {
163 return false;
164 }
165 }
166 None => return false,
167 }
168 }
169 true
170 }
171}
172
173impl Default for Lockfile {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 fn entry(id: &str, version: &str, digest: &str) -> LockfileEntry {
184 LockfileEntry {
185 tool_id: id.to_string(),
186 declared_version_req: String::new(),
187 version: version.to_string(),
188 image_reference: format!("registry/{id}:{version}"),
189 image_digest: digest.to_string(),
190 manifest_sha256: format!("sha256:m-{id}"),
191 image_size_bytes: None,
192 resolved_at: chrono::DateTime::<chrono::Utc>::from_timestamp(1700000000, 0).unwrap(),
193 reference_data_pins: BTreeMap::new(),
194 binaries: vec![format!("{id}-bin")],
195 }
196 }
197
198 #[test]
202 fn to_toml_string_is_deterministic() {
203 let mut lock = Lockfile::new();
204 for id in ["zebra", "alpha", "mango", "beta", "tango"] {
205 lock.tools.insert(
206 id.to_string(),
207 entry(id, "1.0.0", &format!("sha256:d-{id}")),
208 );
209 lock.binary_index
210 .insert(format!("{id}-bin"), id.to_string());
211 }
212
213 let s1 = lock.to_toml_string().unwrap();
214 for _ in 0..32 {
215 assert_eq!(s1, lock.to_toml_string().unwrap(), "non-deterministic output");
216 }
217 let alpha = s1.find("\"alpha\"").unwrap();
219 let beta = s1.find("\"beta\"").unwrap();
220 let mango = s1.find("\"mango\"").unwrap();
221 let tango = s1.find("\"tango\"").unwrap();
222 let zebra = s1.find("\"zebra\"").unwrap();
223 assert!(alpha < beta && beta < mango && mango < tango && tango < zebra);
224 }
225}