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 read_lockfile(path: &Path) -> PrayResult<Lockfile> {
148 let text = fs::read_to_string(path)?;
149 let lockfile = toml::from_str(&text).map_err(|error| PrayError::Parse {
150 kind: "lockfile",
151 message: error.to_string(),
152 })?;
153 Ok(lockfile)
154}
155
156pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
157 let absolute = if path.is_absolute() {
158 path.to_path_buf()
159 } else {
160 project_root.join(path)
161 };
162 let normalized_root = lexical_normalize_path(project_root);
163 let normalized_absolute = lexical_normalize_path(&absolute);
164 let relative = normalized_absolute
165 .strip_prefix(&normalized_root)
166 .map(Path::to_path_buf)
167 .unwrap_or_else(|_| {
168 if path.is_absolute() {
169 path.to_path_buf()
170 } else {
171 lexical_normalize_path(path)
172 }
173 });
174 format_relative_lockfile_path(&relative)
175}
176
177fn format_relative_lockfile_path(relative: &Path) -> String {
178 let text = relative.to_string_lossy().replace('\\', "/");
179 if text == "." || text.starts_with("./") {
180 text
181 } else {
182 format!("./{text}")
183 }
184}
185
186fn lexical_normalize_path(path: &Path) -> PathBuf {
187 let mut normalized = PathBuf::new();
188 for component in path.components() {
189 match component {
190 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
191 Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
192 Component::CurDir => {}
193 Component::ParentDir => {
194 let _ = normalized.pop();
195 }
196 Component::Normal(segment) => normalized.push(segment),
197 }
198 }
199 normalized
200}
201
202fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
203 if let Some(path_text) = artifact.strip_prefix("path:") {
204 let path = Path::new(path_text);
205 let relative = if path.is_absolute() {
206 relative_lockfile_path(project_root, path)
207 } else {
208 relative_lockfile_path(project_root, package_root)
209 };
210 format!("path:{relative}")
211 } else {
212 artifact.to_string()
213 }
214}
215
216#[allow(clippy::too_many_arguments)]
217pub fn build_lockfile(
218 manifest_hash: String,
219 environment: Option<String>,
220 project_root: &Path,
221 manifest_sources: &[crate::manifest::ManifestSource],
222 manifest_targets: &[crate::manifest::ManifestTarget],
223 rendered: &[RenderedTarget],
224 packages: &[crate::resolve::ResolvedPackage],
225 source_revisions: &std::collections::BTreeMap<String, String>,
226 source_host_keys: &std::collections::BTreeMap<String, String>,
227) -> Lockfile {
228 Lockfile {
229 prayfile_lock: "1".to_string(),
230 spec: "0.1".to_string(),
231 generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
232 manifest_hash,
233 environment,
234 source: manifest_sources
235 .iter()
236 .map(|source| LockSource {
237 name: source.name.clone(),
238 kind: source.kind.clone(),
239 url: source.url.clone(),
240 revision: source_revisions.get(&source.name).cloned(),
241 host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
242 })
243 .collect(),
244 package: packages
245 .iter()
246 .map(|package| LockedPackage {
247 name: package.declaration.name.clone(),
248 version: package.spec.version.clone(),
249 source: package.declaration.source.clone(),
250 path: relative_lockfile_path(project_root, &package.root),
251 tree_hash: package.tree_hash.clone(),
252 artifact_hash: package.artifact_hash.clone(),
253 artifact: normalize_lockfile_artifact(
254 project_root,
255 &package.artifact,
256 &package.root,
257 ),
258 exports: package.selected_exports.clone(),
259 dependencies: package
260 .spec
261 .dependencies
262 .iter()
263 .map(|dependency| dependency.name.clone())
264 .collect(),
265 signer_fingerprint: package.signer_fingerprint.clone(),
266 upstream: package.upstream.clone(),
267 })
268 .collect(),
269 target: manifest_targets
270 .iter()
271 .map(|target| LockedTarget {
272 name: target.name.clone(),
273 outputs: target.outputs.clone(),
274 })
275 .collect(),
276 managed_span: rendered
277 .iter()
278 .flat_map(|target| target.managed_spans.iter().cloned())
279 .collect(),
280 provisioned: Vec::new(),
281 }
282 .canonicalized()
283}
284
285#[cfg(test)]
286#[path = "lockfile_unit.rs"]
287mod lockfile_unit;