Skip to main content

pray_core/
registry.rs

1use crate::constraint::version_satisfies;
2use crate::derived_metadata::RegistryDerivedMetadata;
3use crate::manifest::ManifestPackage;
4use crate::package_integrity::{artifact_content_digest, require_remote_integrity_fields};
5use crate::paths::remove_path_if_exists;
6use crate::registry_http::{http_get, http_post, http_put, join_url};
7use crate::registry_ssh::{
8    resolve_ssh_registry_package_root, submit_confession_ssh, upload_registry_artifact_ssh,
9};
10use crate::resolve_context::PackageResolutionContext;
11use crate::{PrayError, PrayResult};
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone)]
18pub struct RegistryPackageResolution {
19    pub root: PathBuf,
20    pub signer_fingerprint: Option<String>,
21    /// Highest non-yanked version published in registry metadata, regardless of Prayfile constraint.
22    pub registry_latest_version: Option<String>,
23}
24
25pub fn lockfile_signer_fingerprint(version: &RegistryPackageVersion) -> Option<String> {
26    version
27        .signer_fingerprint
28        .as_deref()
29        .filter(|value| crate::ssh_identity::looks_like_ssh_fingerprint(value))
30        .map(crate::ssh_identity::normalize_identity)
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
34pub struct RegistryIndex {
35    pub spec: String,
36    #[serde(default)]
37    pub packages: Vec<String>,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub struct RegistryPackageMetadata {
42    pub name: String,
43    #[serde(default)]
44    pub versions: Vec<RegistryPackageVersion>,
45}
46
47#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
48pub struct RegistryPackageVersion {
49    pub version: String,
50    pub artifact: String,
51    #[serde(default)]
52    pub artifact_hash: Option<String>,
53    #[serde(default)]
54    pub tree_hash: Option<String>,
55    #[serde(default)]
56    pub yanked: bool,
57    #[serde(default)]
58    pub targets: Vec<String>,
59    #[serde(default)]
60    pub exports: Vec<String>,
61    #[serde(default)]
62    pub signer: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub signer_fingerprint: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub signer_public_key: Option<String>,
67    #[serde(default)]
68    pub published_at: Option<String>,
69    #[serde(default)]
70    pub signature: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub derived_metadata: Option<RegistryDerivedMetadata>,
73}
74
75impl RegistryPackageVersion {
76    pub fn same_identity(&self, other: &Self) -> bool {
77        self.version == other.version
78            && self.artifact == other.artifact
79            && self.artifact_hash == other.artifact_hash
80            && self.tree_hash == other.tree_hash
81            && self.yanked == other.yanked
82            && self.targets == other.targets
83            && self.exports == other.exports
84            && self.signer == other.signer
85            && self.signer_fingerprint == other.signer_fingerprint
86            && self.signer_public_key == other.signer_public_key
87            && self.published_at == other.published_at
88            && self.signature == other.signature
89    }
90
91    pub fn merge_annotations_from(&mut self, other: &Self) {
92        if self.derived_metadata.is_none() {
93            self.derived_metadata = other.derived_metadata.clone();
94        }
95    }
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
99pub struct ConfessionSubmission {
100    pub package: String,
101    pub version: String,
102    pub status: String,
103    #[serde(default)]
104    pub note: Option<String>,
105    #[serde(default)]
106    pub lockfile: Option<String>,
107    #[serde(default)]
108    pub distribution_point: Option<String>,
109    #[serde(default)]
110    pub signer: Option<String>,
111    #[serde(default)]
112    pub timestamp: Option<String>,
113    #[serde(default)]
114    pub signature: Option<String>,
115}
116
117pub fn resolve_registry_package_root(
118    project_root: &Path,
119    source_url: &str,
120    declaration: &ManifestPackage,
121    context: &PackageResolutionContext,
122) -> PrayResult<RegistryPackageResolution> {
123    if crate::ssh_client::is_pray_ssh_url(source_url) {
124        return resolve_ssh_registry_package_root(project_root, source_url, declaration, context);
125    }
126
127    let metadata = fetch_registry_package_metadata(source_url, &declaration.name)?;
128    let registry_latest_version = registry_latest_version_label(&metadata);
129    let selected = select_package_version(
130        &metadata,
131        &declaration.constraint,
132        context.preferred_version.as_deref(),
133    )?;
134    let signer_fingerprint = lockfile_signer_fingerprint(&selected);
135    require_remote_integrity_fields(&declaration.name, &selected.version, &selected)?;
136    crate::client_trust::gate_pray_ssh_publisher_optional(
137        source_url,
138        signer_fingerprint.as_deref(),
139    )?;
140    if let Some(vendored_root) = crate::registry_cache::try_vendored_package_root(
141        project_root,
142        &declaration.name,
143        &selected,
144    )? {
145        return Ok(RegistryPackageResolution {
146            root: vendored_root,
147            signer_fingerprint,
148            registry_latest_version,
149        });
150    }
151    let cache_directory = crate::registry_cache::registry_cache_directory(
152        project_root,
153        source_url,
154        &declaration.name,
155        &selected.version,
156    );
157
158    if let Some(mut cached) = crate::registry_cache::try_reuse_cached_registry_package(
159        &cache_directory,
160        &selected,
161        signer_fingerprint.clone(),
162    )? {
163        cached.registry_latest_version = registry_latest_version.clone();
164        return Ok(cached);
165    }
166    if context.offline {
167        return Err(crate::registry_cache::offline_package_error(
168            &declaration.name,
169            &selected.version,
170        ));
171    }
172
173    crate::registry_cache::install_registry_artifact_to_cache(
174        &cache_directory,
175        source_url,
176        declaration,
177        &selected,
178    )?;
179
180    Ok(RegistryPackageResolution {
181        root: cache_directory,
182        signer_fingerprint,
183        registry_latest_version,
184    })
185}
186
187pub fn resolve_local_registry_package_root(
188    project_root: &Path,
189    source_key: &str,
190    source_root: &Path,
191    declaration: &ManifestPackage,
192    context: &PackageResolutionContext,
193) -> PrayResult<RegistryPackageResolution> {
194    let metadata_path = source_root.join(format!("v1/packages/{}.json", declaration.name));
195    let metadata_text = fs::read_to_string(&metadata_path).map_err(|error| {
196        if error.kind() == std::io::ErrorKind::NotFound {
197            PrayError::Resolution(format!(
198                "package {} not found in distribution {:?}. \
199                 Missing {}. Check the package name, version constraint `{}`, and that the source publishes registry metadata.",
200                declaration.name,
201                source_root,
202                metadata_path.display(),
203                declaration.constraint
204            ))
205        } else {
206            PrayError::Resolution(format!(
207                "failed to read package metadata {}: {error}",
208                metadata_path.display()
209            ))
210        }
211    })?;
212    let metadata: RegistryPackageMetadata =
213        serde_json::from_str(&metadata_text).map_err(|error| PrayError::Parse {
214            kind: "registry metadata",
215            message: error.to_string(),
216        })?;
217    let registry_latest_version = registry_latest_version_label(&metadata);
218    let selected = select_package_version(
219        &metadata,
220        &declaration.constraint,
221        context.preferred_version.as_deref(),
222    )?;
223    let signer_fingerprint = lockfile_signer_fingerprint(&selected);
224    require_remote_integrity_fields(&declaration.name, &selected.version, &selected)?;
225    crate::client_trust::gate_pray_ssh_publisher_optional(
226        source_key,
227        signer_fingerprint.as_deref(),
228    )?;
229    if let Some(vendored_root) = crate::registry_cache::try_vendored_package_root(
230        project_root,
231        &declaration.name,
232        &selected,
233    )? {
234        return Ok(RegistryPackageResolution {
235            root: vendored_root,
236            signer_fingerprint,
237            registry_latest_version,
238        });
239    }
240    let cache_identifier = format!(
241        "{}:{}:{}:{}",
242        source_key,
243        declaration.name,
244        selected.version,
245        selected
246            .artifact_hash
247            .as_deref()
248            .unwrap_or("no-artifact-hash")
249    );
250    let cache_directory = crate::registry_cache::registry_cache_directory(
251        project_root,
252        &cache_identifier,
253        &declaration.name,
254        &selected.version,
255    );
256
257    if let Some(mut cached) = crate::registry_cache::try_reuse_cached_registry_package(
258        &cache_directory,
259        &selected,
260        signer_fingerprint.clone(),
261    )? {
262        cached.registry_latest_version = registry_latest_version.clone();
263        return Ok(cached);
264    }
265    if context.offline {
266        return Err(crate::registry_cache::offline_package_error(
267            &declaration.name,
268            &selected.version,
269        ));
270    }
271
272    if cache_directory.exists() {
273        remove_path_if_exists(&cache_directory)?;
274    }
275    fs::create_dir_all(&cache_directory)?;
276
277    let artifact_bytes =
278        crate::registry_cache::read_local_registry_artifact_bytes(source_root, &selected.artifact)?;
279    crate::registry_cache::validate_and_unpack_registry_package(
280        &cache_directory,
281        declaration,
282        &selected,
283        &artifact_bytes,
284    )?;
285
286    Ok(RegistryPackageResolution {
287        root: cache_directory,
288        signer_fingerprint,
289        registry_latest_version,
290    })
291}
292
293pub fn registry_package_signing_identity(version: &RegistryPackageVersion) -> Option<String> {
294    crate::ssh_identity::package_signing_identity(
295        version.signer.as_deref(),
296        version.signer_fingerprint.as_deref(),
297    )
298}
299
300pub fn registry_artifact_signature(artifact_bytes: &[u8], tree_hash: &str, signer: &str) -> String {
301    artifact_content_digest(artifact_bytes, tree_hash, signer)
302}
303
304pub fn submit_confession(source_url: &str, confession: &ConfessionSubmission) -> PrayResult<()> {
305    if crate::ssh_client::is_pray_ssh_url(source_url) {
306        return submit_confession_ssh(source_url, confession);
307    }
308    let endpoint = join_url(source_url, "v1/confessions");
309    let payload =
310        serde_json::to_vec(confession).map_err(|error| PrayError::Manifest(error.to_string()))?;
311    let response = http_post(&endpoint, "application/json", &payload)?;
312    if response.status / 100 != 2 {
313        return Err(PrayError::Resolution(format!(
314            "confession submission failed with HTTP {}",
315            response.status
316        )));
317    }
318    Ok(())
319}
320
321pub fn upload_registry_artifact(
322    source_url: &str,
323    artifact_path: &str,
324    bytes: &[u8],
325) -> PrayResult<()> {
326    if crate::ssh_client::is_pray_ssh_url(source_url) {
327        return upload_registry_artifact_ssh(source_url, artifact_path, bytes);
328    }
329    let endpoint = join_url(source_url, artifact_path);
330    let response = http_put(&endpoint, "application/octet-stream", bytes)?;
331    if response.status / 100 != 2 {
332        return Err(PrayError::Resolution(format!(
333            "artifact upload failed with HTTP {}",
334            response.status
335        )));
336    }
337    Ok(())
338}
339
340fn fetch_registry_package_metadata(
341    source_url: &str,
342    package_name: &str,
343) -> PrayResult<RegistryPackageMetadata> {
344    let url = join_url(source_url, &format!("v1/packages/{}.json", package_name));
345    let response = http_get(&url)?;
346    serde_json::from_slice(&response).map_err(|error| PrayError::Parse {
347        kind: "registry metadata",
348        message: error.to_string(),
349    })
350}
351
352pub fn highest_registry_version(
353    metadata: &RegistryPackageMetadata,
354) -> PrayResult<Option<RegistryPackageVersion>> {
355    let mut selected: Option<RegistryPackageVersion> = None;
356    for version in &metadata.versions {
357        if version.yanked {
358            continue;
359        }
360        match &selected {
361            Some(existing) if compare_versions(&version.version, &existing.version)? <= 0 => {}
362            _ => selected = Some(version.clone()),
363        }
364    }
365    Ok(selected)
366}
367
368pub fn registry_latest_version_label(metadata: &RegistryPackageMetadata) -> Option<String> {
369    highest_registry_version(metadata)
370        .ok()
371        .flatten()
372        .map(|version| version.version)
373}
374
375pub fn version_is_greater_than(left: &str, right: &str) -> PrayResult<bool> {
376    Ok(compare_versions(left, right)? > 0)
377}
378
379pub(crate) fn select_package_version(
380    metadata: &RegistryPackageMetadata,
381    constraint: &str,
382    preferred_version: Option<&str>,
383) -> PrayResult<RegistryPackageVersion> {
384    if let Some(preferred_version) = preferred_version {
385        if let Some(version) = metadata
386            .versions
387            .iter()
388            .find(|version| version.version == preferred_version && !version.yanked)
389        {
390            if version_satisfies(&version.version, constraint)? {
391                return Ok(version.clone());
392            }
393            // Prayfile constraint changed; fall through to the highest satisfying version.
394        }
395    }
396    let mut selected: Option<RegistryPackageVersion> = None;
397    for version in &metadata.versions {
398        if version.yanked {
399            continue;
400        }
401        if !version_satisfies(&version.version, constraint)? {
402            continue;
403        }
404        match &selected {
405            Some(existing) if compare_versions(&version.version, &existing.version)? <= 0 => {}
406            _ => selected = Some(version.clone()),
407        }
408    }
409    selected.ok_or_else(|| {
410        PrayError::Resolution(format!(
411            "no registry version for {} satisfies {}",
412            metadata.name, constraint
413        ))
414    })
415}
416
417#[doc(hidden)]
418pub fn select_package_version_for_test(
419    metadata: &RegistryPackageMetadata,
420    constraint: &str,
421    preferred_version: Option<&str>,
422) -> PrayResult<RegistryPackageVersion> {
423    select_package_version(metadata, constraint, preferred_version)
424}
425
426fn compare_versions(left: &str, right: &str) -> PrayResult<i32> {
427    let left = Version::parse(left).map_err(|error| PrayError::Resolution(error.to_string()))?;
428    let right = Version::parse(right).map_err(|error| PrayError::Resolution(error.to_string()))?;
429    Ok(match left.cmp(&right) {
430        std::cmp::Ordering::Less => -1,
431        std::cmp::Ordering::Equal => 0,
432        std::cmp::Ordering::Greater => 1,
433    })
434}
435
436pub fn fetch_optional_distribution_bytes(
437    source_url: &str,
438    relative_path: &str,
439) -> PrayResult<Option<Vec<u8>>> {
440    if !source_url.starts_with("http://") && !source_url.starts_with("https://") {
441        return Err(PrayError::Unsupported(format!(
442            "distribution fetch requires http or https source, got {source_url}"
443        )));
444    }
445    let url = join_url(source_url, relative_path);
446    match http_get(&url) {
447        Ok(bytes) => Ok(Some(bytes)),
448        Err(PrayError::Resolution(message)) if message.contains("404") => Ok(None),
449        Err(error) => Err(error),
450    }
451}