use std::fmt;
use url::Url;
const NPM_METADATA: &str = "https://registry.npmjs.org";
const PYPI_METADATA: &str = "https://pypi.org";
const PYPI_ARTIFACTS: &str = "https://files.pythonhosted.org";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum OriginKind {
NpmMetadata,
PypiMetadata,
PypiArtifacts,
}
impl OriginKind {
pub const ALL: [OriginKind; 3] = [
OriginKind::NpmMetadata,
OriginKind::PypiMetadata,
OriginKind::PypiArtifacts,
];
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum UrlRejection {
Scheme,
Host,
Port,
Credentials,
ForeignOrigin,
PathEscape,
}
impl fmt::Display for UrlRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let reason = match self {
UrlRejection::Scheme => "the scheme is not https",
UrlRejection::Host => "the host is not a configured upstream origin",
UrlRejection::Port => "the port is not the configured origin's port",
UrlRejection::Credentials => "the URL carries credentials",
UrlRejection::ForeignOrigin => "the host belongs to a different upstream origin",
UrlRejection::PathEscape => "the path leaves the configured origin's path",
};
f.write_str(reason)
}
}
#[derive(Clone, Debug)]
pub struct OriginSet {
npm_metadata: Url,
pypi_metadata: Url,
pypi_artifacts: Url,
allow_private_addresses: bool,
}
impl OriginSet {
pub fn production() -> OriginSet {
OriginSet {
npm_metadata: parse_origin(NPM_METADATA),
pypi_metadata: parse_origin(PYPI_METADATA),
pypi_artifacts: parse_origin(PYPI_ARTIFACTS),
allow_private_addresses: false,
}
}
pub fn for_tests(npm: Url, pypi: Url, artifacts: Url) -> OriginSet {
OriginSet {
npm_metadata: npm,
pypi_metadata: pypi,
pypi_artifacts: artifacts,
allow_private_addresses: true,
}
}
pub fn origin(&self, kind: OriginKind) -> &Url {
match kind {
OriginKind::NpmMetadata => &self.npm_metadata,
OriginKind::PypiMetadata => &self.pypi_metadata,
OriginKind::PypiArtifacts => &self.pypi_artifacts,
}
}
pub fn allows_private_addresses(&self) -> bool {
self.allow_private_addresses
}
pub fn kind_of(&self, url: &Url) -> Option<OriginKind> {
OriginKind::ALL
.into_iter()
.find(|kind| same_origin(url, self.origin(*kind)))
}
pub fn url_for(&self, kind: OriginKind, segments: &[&str]) -> Result<Url, UrlRejection> {
if segments
.iter()
.any(|segment| matches!(*segment, "." | ".."))
{
return Err(UrlRejection::PathEscape);
}
let mut url = self.origin(kind).clone();
{
let mut path = url
.path_segments_mut()
.map_err(|()| UrlRejection::PathEscape)?;
path.pop_if_empty();
for segment in segments {
path.push(segment);
}
}
self.admit(&url, kind)?;
Ok(url)
}
pub fn admit(&self, url: &Url, kind: OriginKind) -> Result<(), UrlRejection> {
if !self.permits_scheme(url.scheme()) {
return Err(UrlRejection::Scheme);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(UrlRejection::Credentials);
}
let origin = self.origin(kind);
if !same_origin(url, origin) && self.kind_of(url).is_some() {
return Err(UrlRejection::ForeignOrigin);
}
if url.host_str() != origin.host_str() {
return Err(UrlRejection::Host);
}
if url.port_or_known_default() != origin.port_or_known_default() {
return Err(UrlRejection::Port);
}
if url.scheme() != origin.scheme() {
return Err(UrlRejection::Scheme);
}
if !path_is_under(url.path(), origin.path()) {
return Err(UrlRejection::PathEscape);
}
Ok(())
}
pub fn admit_any(&self, url: &Url) -> Result<OriginKind, UrlRejection> {
if let Some(kind) = self.kind_of(url) {
self.admit(url, kind)?;
return Ok(kind);
}
let closest = OriginKind::ALL
.into_iter()
.find(|kind| self.origin(*kind).host_str() == url.host_str())
.unwrap_or(OriginKind::NpmMetadata);
Err(self.admit(url, closest).err().unwrap_or(UrlRejection::Host))
}
fn permits_scheme(&self, scheme: &str) -> bool {
scheme == "https" || (self.allow_private_addresses && scheme == "http")
}
}
fn parse_origin(text: &str) -> Url {
Url::parse(text).expect("a fixed upstream origin parses")
}
fn same_origin(url: &Url, origin: &Url) -> bool {
url.scheme() == origin.scheme()
&& url.host_str() == origin.host_str()
&& url.port_or_known_default() == origin.port_or_known_default()
}
fn path_is_under(path: &str, prefix: &str) -> bool {
if path.split('/').any(|segment| segment == "..") {
return false;
}
let prefix = prefix.trim_end_matches('/');
if prefix.is_empty() {
return true;
}
path == prefix || path.starts_with(&format!("{prefix}/"))
}