use super::body::{check_body_cap, read_body_capped};
use super::cache::packument_full_cache_path;
use super::{
AUDIT_BODY_CAP, PACKUMENT_FULL_ACCEPT, RegistryClient, check_dist_tag_status,
dist_tag_root_url, dist_tag_url, parse_full_response,
};
use crate::Error;
use serde::Deserialize;
use std::borrow::Cow;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageSearchResult {
pub name: String,
pub version: String,
pub description: Option<String>,
}
#[derive(Deserialize)]
struct PackageSearchResponse {
#[serde(default)]
objects: Vec<PackageSearchObject>,
}
#[derive(Deserialize)]
struct PackageSearchObject {
package: PackageSearchPackage,
}
#[derive(Deserialize)]
struct PackageSearchPackage {
name: String,
#[serde(default)]
version: String,
description: Option<String>,
}
impl RegistryClient {
pub async fn search_packages(
&self,
query: &str,
limit: usize,
timeout: std::time::Duration,
) -> Result<Vec<PackageSearchResult>, Error> {
let routing_name = if query.starts_with('@') && !query.contains('/') {
Cow::Owned(format!("{query}/"))
} else {
Cow::Borrowed(query)
};
let registry_url = self.config.registry_for(&routing_name);
let mut url = reqwest::Url::parse(&format!(
"{}/-/v1/search",
registry_url.trim_end_matches('/')
))
.map_err(|error| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, error)))?;
url.query_pairs_mut()
.append_pair("text", query)
.append_pair("size", &limit.clamp(1, 250).to_string());
let response = self
.authed_for_package(
self.http_for_package(registry_url, &routing_name).get(url),
registry_url,
&routing_name,
)
.timeout(timeout)
.header("Accept", "application/json")
.send()
.await?
.error_for_status()?;
let bytes = read_body_capped(response, 2 << 20, "package search").await?;
let body: PackageSearchResponse = serde_json::from_slice(&bytes)
.map_err(|error| Error::Io(std::io::Error::other(error)))?;
Ok(body
.objects
.into_iter()
.map(|entry| PackageSearchResult {
name: entry.package.name,
version: entry.package.version,
description: entry.package.description,
})
.collect())
}
pub async fn fetch_advisories_bulk(
&self,
pkg_versions: &std::collections::BTreeMap<String, Vec<String>>,
) -> Result<serde_json::Value, Error> {
let registry_url = &self.config.registry;
let url = format!(
"{}/-/npm/v1/security/advisories/bulk",
registry_url.trim_end_matches('/')
);
let body = serde_json::to_vec(pkg_versions)
.map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
let resp = self
.authed(self.http_for(registry_url).post(&url), registry_url)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(serde_json::Value::Object(serde_json::Map::new()));
}
let resp = resp.error_for_status()?;
check_body_cap(&resp, AUDIT_BODY_CAP, "bulk advisories")?;
let json: serde_json::Value = resp.json().await?;
Ok(json)
}
pub async fn fetch_single_version_metadata(
&self,
name: &str,
version: &str,
) -> Result<crate::VersionMetadata, Error> {
let (packument_url, registry_url) = self.packument_url(name);
let url = format!("{packument_url}/{version}");
let resp = self
.send_metadata_with_retry(&format!("version {name}@{version}"), || {
self.authed_get_for_package(&url, registry_url, name)
.header("Accept", "application/json")
})
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound(format!("{name}@{version}")));
}
let resp = resp.error_for_status()?;
check_body_cap(
&resp,
self.fetch_policy.packument_max_bytes,
"version-metadata",
)?;
parse_full_response(resp).await
}
pub async fn fetch_packument_json_fresh(&self, name: &str) -> Result<serde_json::Value, Error> {
let (url, registry_url) = self.packument_url(name);
let resp = self
.send_metadata_with_retry(&format!("packument {name}"), || {
self.authed_get_for_package(&url, registry_url, name)
.header("Accept", PACKUMENT_FULL_ACCEPT)
})
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound(name.to_string()));
}
let resp = resp.error_for_status()?;
check_body_cap(&resp, self.fetch_policy.packument_max_bytes, "packument")?;
let value: serde_json::Value = resp.json().await?;
Ok(value)
}
pub async fn put_packument(
&self,
name: &str,
body: &serde_json::Value,
otp: Option<&str>,
) -> Result<serde_json::Value, Error> {
let (url, registry_url) = self.packument_url(name);
let mut req = self.authed_for_package(
self.http_for_package(registry_url, name)
.put(&url)
.header("Content-Type", "application/json")
.json(body),
registry_url,
name,
);
if let Some(code) = otp {
req = req.header("npm-otp", code);
}
let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::RegistryWrite {
status: status.as_u16(),
body,
});
}
let value: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
Ok(value)
}
pub fn invalidate_full_packument_cache(&self, name: &str, cache_dir: &Path) {
let registry_url = self.config.registry_for(name).to_string();
if let Some(path) = packument_full_cache_path(cache_dir, name, ®istry_url) {
let _ = std::fs::remove_file(&path);
}
}
pub async fn fetch_dist_tags(
&self,
name: &str,
) -> Result<std::collections::BTreeMap<String, String>, Error> {
let registry_url = self.registry_url_for(name);
let url = dist_tag_root_url(registry_url, name);
let resp = self
.send_metadata_with_retry(&format!("dist-tags {name}"), || {
self.authed_get_for_package(&url, registry_url, name)
})
.await?;
check_dist_tag_status(&resp, name)?;
let map: std::collections::BTreeMap<String, String> =
resp.error_for_status()?.json().await?;
Ok(map)
}
pub async fn put_dist_tag(
&self,
name: &str,
tag: &str,
version: &str,
otp: Option<&str>,
) -> Result<(), Error> {
let registry_url = self.registry_url_for(name);
let url = dist_tag_url(registry_url, name, tag);
let body = serde_json::to_string(version).map_err(std::io::Error::other)?;
let mut req = self
.http_for_package(registry_url, name)
.put(&url)
.header("Content-Type", "application/json")
.body(body);
if self.config.is_public_npmjs(name) {
req = req.header("npm-auth-type", "web");
}
let req = if let Some(code) = otp {
req.header("npm-otp", code)
} else {
req
};
let resp = self
.authed_for_package(req, registry_url, name)
.send()
.await?;
check_dist_tag_status(&resp, name)?;
resp.error_for_status()?;
Ok(())
}
pub async fn delete_dist_tag(
&self,
name: &str,
tag: &str,
otp: Option<&str>,
) -> Result<(), Error> {
let registry_url = self.registry_url_for(name);
let url = dist_tag_url(registry_url, name, tag);
let mut req = self.http_for_package(registry_url, name).delete(&url);
if self.config.is_public_npmjs(name) {
req = req.header("npm-auth-type", "web");
}
let req = if let Some(code) = otp {
req.header("npm-otp", code)
} else {
req
};
let resp = self
.authed_for_package(req, registry_url, name)
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound(format!("{name}@{tag}")));
}
if matches!(
resp.status(),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
return Err(Error::Unauthorized);
}
resp.error_for_status()?;
Ok(())
}
pub fn tarball_url(&self, name: &str, version: &str) -> String {
let registry_url = self.registry_url_for(name);
let registry = registry_url.trim_end_matches('/');
let unscoped = if let Some(rest) = name.strip_prefix('@') {
rest.split('/').nth(1).unwrap_or(rest)
} else {
name
};
format!("{registry}/{name}/-/{unscoped}-{version}.tgz")
}
}
#[cfg(test)]
mod search_tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn package_search_uses_registry_endpoint_and_parses_descriptions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/-/v1/search"))
.and(query_param("text", "rea"))
.and(query_param("size", "5"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"objects": [{
"package": {
"name": "react",
"version": "19.1.0",
"description": "React is a JavaScript library"
}
}]
})))
.mount(&server)
.await;
let client = RegistryClient::new(&server.uri());
let results = client
.search_packages("rea", 5, std::time::Duration::from_secs(1))
.await
.unwrap();
assert_eq!(
results,
vec![PackageSearchResult {
name: "react".to_string(),
version: "19.1.0".to_string(),
description: Some("React is a JavaScript library".to_string()),
}]
);
}
#[tokio::test]
async fn scoped_package_search_uses_its_configured_registry() {
let default_server = MockServer::start().await;
let scoped_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/-/v1/search"))
.and(query_param("text", "@acme/tool"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"objects": [{
"package": {"name": "@acme/tool", "version": "2.0.0"}
}]
})))
.expect(1)
.mount(&scoped_server)
.await;
let mut config = crate::config::NpmConfig {
registry: default_server.uri(),
..Default::default()
};
config
.scoped_registries
.insert("@acme".to_string(), scoped_server.uri());
let client = RegistryClient::from_config(config);
let results = client
.search_packages("@acme/tool", 5, std::time::Duration::from_secs(1))
.await
.unwrap();
assert_eq!(results[0].name, "@acme/tool");
}
#[tokio::test]
async fn incomplete_scope_search_uses_its_configured_registry() {
let default_server = MockServer::start().await;
let scoped_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/-/v1/search"))
.and(query_param("text", "@acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"objects": [{
"package": {"name": "@acme/tool", "version": "2.0.0"}
}]
})))
.expect(1)
.mount(&scoped_server)
.await;
let mut config = crate::config::NpmConfig {
registry: default_server.uri(),
..Default::default()
};
config
.scoped_registries
.insert("@acme".to_string(), scoped_server.uri());
let client = RegistryClient::from_config(config);
let results = client
.search_packages("@acme", 5, std::time::Duration::from_secs(1))
.await
.unwrap();
assert_eq!(results[0].name, "@acme/tool");
}
}