1use crate::hashing::sha256_prefixed;
2use crate::render::RenderedTarget;
3use crate::{PrayError, PrayResult};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9pub struct Lockfile {
10 pub prayfile_lock: String,
11 pub spec: String,
12 pub generated_by: String,
13 pub manifest_hash: String,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub environment: Option<String>,
16 pub source: Vec<LockSource>,
17 pub package: Vec<LockedPackage>,
18 pub target: Vec<LockedTarget>,
19 pub managed_span: Vec<ManagedSpanRecord>,
20 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub provisioned: Vec<ProvisionedFileRecord>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LockSource {
26 pub name: String,
27 pub kind: String,
28 pub url: String,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub revision: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub host_key_fingerprint: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct LockedPackage {
37 pub name: String,
38 pub version: String,
39 pub source: Option<String>,
40 pub path: String,
41 pub tree_hash: String,
42 pub artifact_hash: String,
43 pub artifact: String,
44 pub exports: Vec<String>,
45 pub dependencies: Vec<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub signer_fingerprint: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub upstream: Option<crate::package_upstream::LockedUpstream>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct LockedTarget {
54 pub name: String,
55 pub outputs: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct ManagedSpanRecord {
60 pub id: String,
61 pub target: String,
62 pub open_line: usize,
63 pub close_line: usize,
64 pub ideal_checksum: String,
65 pub package: String,
66 pub export: String,
67 pub source_checksum: String,
68 pub silenced: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ProvisionedFileRecord {
73 pub path: String,
74 pub content_hash: String,
75 pub package: String,
76 pub export: String,
77}
78
79impl Lockfile {
80 pub fn canonicalized(&self) -> Self {
81 let mut lockfile = self.clone();
82 lockfile
83 .source
84 .sort_by(|left, right| left.name.cmp(&right.name));
85 lockfile.package.sort_by(|left, right| {
86 left.name
87 .cmp(&right.name)
88 .then(left.source.cmp(&right.source))
89 .then(left.version.cmp(&right.version))
90 });
91 lockfile
92 .target
93 .sort_by(|left, right| left.name.cmp(&right.name));
94 lockfile.managed_span.sort_by(|left, right| {
95 left.target
96 .cmp(&right.target)
97 .then(left.open_line.cmp(&right.open_line))
98 .then(left.id.cmp(&right.id))
99 });
100 lockfile.provisioned.sort_by(|left, right| {
101 left.path
102 .cmp(&right.path)
103 .then(left.package.cmp(&right.package))
104 });
105 lockfile
106 }
107
108 pub fn serialized(&self) -> PrayResult<String> {
109 let bytes = toml::to_string_pretty(&self.canonicalized())
110 .map_err(|error| PrayError::Manifest(error.to_string()))?;
111 Ok(bytes)
112 }
113
114 pub fn file_hash(&self) -> PrayResult<String> {
115 let text = self.serialized()?;
116 Ok(sha256_prefixed(text.as_bytes()))
117 }
118
119 pub fn equivalent_to(&self, other: &Self) -> bool {
120 self == &other.canonicalized()
121 }
122}
123
124pub fn lockfiles_equivalent(canonical: &Lockfile, other: &Lockfile) -> bool {
125 canonical.equivalent_to(other)
126}
127
128pub fn write_lockfile_if_changed(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
129 let serialized = lockfile.serialized()?;
130 if path.exists() {
131 if let Ok(existing) = fs::read(path) {
132 if existing == serialized.as_bytes() {
133 return Ok(());
134 }
135 }
136 }
137 crate::transaction::write_file(path, serialized)?;
138 Ok(())
139}
140
141pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
142 let serialized = lockfile.serialized()?;
143 crate::transaction::write_file(path, serialized)?;
144 Ok(())
145}
146
147pub fn parse_lockfile(text: &str) -> PrayResult<Lockfile> {
148 toml::from_str(text).map_err(|error| PrayError::Parse {
149 kind: "lockfile",
150 message: error.to_string(),
151 })
152}
153
154pub fn serialize_lockfile(lockfile: &Lockfile) -> PrayResult<String> {
155 lockfile.serialized()
156}
157
158pub fn lockfile_hash(lockfile: &Lockfile) -> PrayResult<String> {
159 lockfile.file_hash()
160}
161
162pub fn read_lockfile(path: &Path) -> PrayResult<Lockfile> {
163 parse_lockfile(&fs::read_to_string(path)?)
164}
165
166pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
167 let absolute = if path.is_absolute() {
168 path.to_path_buf()
169 } else {
170 project_root.join(path)
171 };
172 let normalized_root = lexical_normalize_path(project_root);
173 let normalized_absolute = lexical_normalize_path(&absolute);
174 let relative = normalized_absolute
175 .strip_prefix(&normalized_root)
176 .map(Path::to_path_buf)
177 .unwrap_or_else(|_| {
178 if path.is_absolute() {
179 path.to_path_buf()
180 } else {
181 lexical_normalize_path(path)
182 }
183 });
184 format_relative_lockfile_path(&relative)
185}
186
187fn format_relative_lockfile_path(relative: &Path) -> String {
188 let text = relative.to_string_lossy().replace('\\', "/");
189 if text == "." || text.starts_with("./") {
190 text
191 } else {
192 format!("./{text}")
193 }
194}
195
196fn lexical_normalize_path(path: &Path) -> PathBuf {
197 let mut normalized = PathBuf::new();
198 for component in path.components() {
199 match component {
200 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
201 Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
202 Component::CurDir => {}
203 Component::ParentDir => {
204 let _ = normalized.pop();
205 }
206 Component::Normal(segment) => normalized.push(segment),
207 }
208 }
209 normalized
210}
211
212fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
213 if let Some(path_text) = artifact.strip_prefix("path:") {
214 let path = Path::new(path_text);
215 let relative = if path.is_absolute() {
216 relative_lockfile_path(project_root, path)
217 } else {
218 relative_lockfile_path(project_root, package_root)
219 };
220 format!("path:{relative}")
221 } else {
222 artifact.to_string()
223 }
224}
225
226#[allow(clippy::too_many_arguments)]
227pub fn build_lockfile(
228 manifest_hash: String,
229 environment: Option<String>,
230 project_root: &Path,
231 manifest_sources: &[crate::manifest::ManifestSource],
232 manifest_targets: &[crate::manifest::ManifestTarget],
233 rendered: &[RenderedTarget],
234 packages: &[crate::resolve::ResolvedPackage],
235 source_revisions: &std::collections::BTreeMap<String, String>,
236 source_host_keys: &std::collections::BTreeMap<String, String>,
237) -> Lockfile {
238 Lockfile {
239 prayfile_lock: "1".to_string(),
240 spec: "0.1".to_string(),
241 generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
242 manifest_hash,
243 environment,
244 source: manifest_sources
245 .iter()
246 .map(|source| LockSource {
247 name: source.name.clone(),
248 kind: source.kind.clone(),
249 url: source.url.clone(),
250 revision: source_revisions.get(&source.name).cloned(),
251 host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
252 })
253 .collect(),
254 package: packages
255 .iter()
256 .map(|package| LockedPackage {
257 name: package.declaration.name.clone(),
258 version: package.spec.version.clone(),
259 source: package.declaration.source.clone(),
260 path: relative_lockfile_path(project_root, &package.root),
261 tree_hash: package.tree_hash.clone(),
262 artifact_hash: package.artifact_hash.clone(),
263 artifact: normalize_lockfile_artifact(
264 project_root,
265 &package.artifact,
266 &package.root,
267 ),
268 exports: package.selected_exports.clone(),
269 dependencies: package
270 .spec
271 .dependencies
272 .iter()
273 .map(|dependency| dependency.name.clone())
274 .collect(),
275 signer_fingerprint: package.signer_fingerprint.clone(),
276 upstream: package.upstream.clone(),
277 })
278 .collect(),
279 target: manifest_targets
280 .iter()
281 .map(|target| LockedTarget {
282 name: target.name.clone(),
283 outputs: target.outputs.clone(),
284 })
285 .collect(),
286 managed_span: rendered
287 .iter()
288 .flat_map(|target| target.managed_spans.iter().cloned())
289 .collect(),
290 provisioned: Vec::new(),
291 }
292 .canonicalized()
293}
294
295#[cfg(test)]
296#[path = "lockfile_unit.rs"]
297mod lockfile_unit;