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