use std::num::TryFromIntError;
use thiserror::Error;
use url::Url;
use crate::dependency::DependencyName;
use crate::resolver::DependencyScope;
use crate::resolver::config::LargeFileWarning;
use crate::resolver::config::ModulesConfig;
use crate::resolver::error::ResolverError;
use crate::resolver::git::CredentialMode;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ResolverPolicyError {
#[error("invalid maximum advertised references value")]
InvalidMaxAdvertisedRefs {
value: u64,
#[source]
source: TryFromIntError,
},
#[error("invalid maximum materialized files value")]
InvalidMaxMaterializedFiles {
value: u64,
#[source]
source: TryFromIntError,
},
}
#[derive(Clone, Debug)]
pub(crate) enum HostPolicy {
Any,
AllowList(Vec<String>),
}
impl HostPolicy {
fn allows(&self, host: &str) -> bool {
match self {
Self::Any => true,
Self::AllowList(list) => list.iter().any(|h| h.eq_ignore_ascii_case(host)),
}
}
fn explicitly_allows(&self, host: &str) -> bool {
match self {
Self::Any => false,
Self::AllowList(list) => list.iter().any(|h| h.eq_ignore_ascii_case(host)),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct GitNetworkPolicy {
pub(crate) allowed_schemes: Vec<String>,
pub(crate) host_policy: HostPolicy,
pub(crate) max_advertised_refs: usize,
}
#[derive(Clone, Debug)]
pub struct ResolverPolicy {
top_level: GitNetworkPolicy,
transitive: GitNetworkPolicy,
denied_hosts: Vec<String>,
pub(crate) max_materialized_files: Option<usize>,
pub(crate) max_materialized_bytes: Option<u64>,
pub(crate) large_file_warning: LargeFileWarning,
pub(crate) require_signed: bool,
credentials_enabled: bool,
}
impl Default for ResolverPolicy {
fn default() -> Self {
Self::try_from(&ModulesConfig::default()).expect("default module configuration is invalid")
}
}
impl TryFrom<&ModulesConfig> for ResolverPolicy {
type Error = ResolverPolicyError;
fn try_from(config: &ModulesConfig) -> Result<Self, Self::Error> {
let top_host = if config.allowed_hosts.is_empty() {
HostPolicy::Any
} else {
HostPolicy::AllowList(config.allowed_hosts.clone())
};
let transitive_host = if config.allowed_transitive_hosts.is_empty() {
HostPolicy::Any
} else {
HostPolicy::AllowList(config.allowed_transitive_hosts.clone())
};
Ok(Self {
top_level: GitNetworkPolicy {
allowed_schemes: config.allowed_schemes.clone(),
host_policy: top_host,
max_advertised_refs: config.max_advertised_refs.try_into().map_err(|e| {
ResolverPolicyError::InvalidMaxAdvertisedRefs {
value: config.max_advertised_refs,
source: e,
}
})?,
},
transitive: GitNetworkPolicy {
allowed_schemes: config.allowed_transitive_schemes.clone(),
host_policy: transitive_host,
max_advertised_refs: config.max_advertised_refs.try_into().map_err(|e| {
ResolverPolicyError::InvalidMaxAdvertisedRefs {
value: config.max_advertised_refs,
source: e,
}
})?,
},
denied_hosts: config.denied_hosts.clone(),
max_materialized_files: config
.max_materialized_files
.map(|v| {
v.try_into()
.map_err(|e| ResolverPolicyError::InvalidMaxMaterializedFiles {
value: v,
source: e,
})
})
.transpose()?,
max_materialized_bytes: config.max_materialized_bytes,
large_file_warning: config.large_file_warning,
require_signed: config.require_signed,
credentials_enabled: true,
})
}
}
impl ResolverPolicy {
pub fn without_credentials(mut self) -> Self {
self.credentials_enabled = false;
self
}
pub(crate) fn git_policy(&self, scope: DependencyScope) -> &GitNetworkPolicy {
match scope {
DependencyScope::TopLevel => &self.top_level,
DependencyScope::Transitive => &self.transitive,
}
}
pub(crate) fn credential_mode(
&self,
scope: DependencyScope,
host: Option<&str>,
) -> CredentialMode {
if !self.credentials_enabled {
return CredentialMode::Disabled;
}
match scope {
DependencyScope::TopLevel => CredentialMode::Enabled,
DependencyScope::Transitive => match host {
Some(host) if self.transitive.host_policy.explicitly_allows(host) => {
CredentialMode::Enabled
}
_ => CredentialMode::Disabled,
},
}
}
pub(crate) fn check_git_url(
&self,
name: &DependencyName,
url: &Url,
scope: DependencyScope,
) -> Result<(), ResolverError> {
let net = self.git_policy(scope);
if !net
.allowed_schemes
.iter()
.any(|s| s.eq_ignore_ascii_case(url.scheme()))
{
return Err(ResolverError::GitUrlPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
scheme: url.scheme().to_string(),
});
}
if let Some(host) = url.host_str() {
if self
.denied_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(host))
{
return Err(ResolverError::GitHostPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
});
}
if super::config::is_non_public_ip(host) {
return Err(ResolverError::GitHostPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
});
}
if !net.host_policy.allows(host) {
return Err(ResolverError::GitHostNotAllowed {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
config_key: match scope {
DependencyScope::TopLevel => "allowed_hosts",
DependencyScope::Transitive => "allowed_transitive_hosts",
},
});
}
if host.parse::<std::net::IpAddr>().is_err() && url.scheme() != "file" {
let addrs: Vec<std::net::SocketAddr> =
match std::net::ToSocketAddrs::to_socket_addrs(&(host, 0)) {
Ok(iter) => iter.collect(),
Err(_) => {
return Err(ResolverError::GitHostResolutionFailed {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
});
}
};
if let Err(bad_ip) = validate_resolved_addresses(&addrs) {
return match bad_ip {
Some(ip) => Err(ResolverError::GitHostPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
host: format!("{host} (resolves to {ip})"),
}),
None => Err(ResolverError::GitHostResolutionFailed {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
}),
};
}
}
}
Ok(())
}
}
fn validate_resolved_addresses(addrs: &[std::net::SocketAddr]) -> Result<(), Option<String>> {
if addrs.is_empty() {
return Err(None);
}
for addr in addrs {
let ip = addr.ip().to_string();
if super::config::is_non_public_ip(&ip) {
return Err(Some(ip));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolver::config::ModulesConfig;
use crate::resolver::error::ResolverError;
#[test]
fn blocks_file_scheme() {
let policy = ResolverPolicy::default();
let dep = "foo".parse().unwrap();
let url: url::Url = "file:///tmp/repo".parse().unwrap();
let err = policy
.check_git_url(&dep, &url, DependencyScope::TopLevel)
.unwrap_err();
assert!(
matches!(err, ResolverError::GitUrlPolicyViolation { .. }),
"got: {err}"
);
}
#[test]
fn allows_ssh_top_level_blocks_transitive() {
let policy = ResolverPolicy::default();
let dep = "foo".parse().unwrap();
let url: url::Url = "ssh://git@github.com/x/y".parse().unwrap();
policy
.check_git_url(&dep, &url, DependencyScope::TopLevel)
.unwrap();
let err = policy
.check_git_url(&dep, &url, DependencyScope::Transitive)
.unwrap_err();
assert!(matches!(err, ResolverError::GitUrlPolicyViolation { .. }));
}
#[test]
fn allows_https_by_default() {
let policy = ResolverPolicy::default();
let dep = "foo".parse().unwrap();
let url: url::Url = "https://github.com/x/y".parse().unwrap();
policy
.check_git_url(&dep, &url, DependencyScope::TopLevel)
.unwrap();
policy
.check_git_url(&dep, &url, DependencyScope::Transitive)
.unwrap();
}
#[test]
fn transitive_credentials_require_host_allowlist() {
let default_policy = ResolverPolicy::default();
assert_eq!(
default_policy.credential_mode(DependencyScope::Transitive, Some("github.com")),
CredentialMode::Enabled
);
assert_eq!(
default_policy.credential_mode(DependencyScope::Transitive, Some("bitbucket.org")),
CredentialMode::Disabled
);
assert_eq!(
default_policy.credential_mode(DependencyScope::TopLevel, Some("bitbucket.org")),
CredentialMode::Enabled
);
let open = ResolverPolicy::try_from(&ModulesConfig {
allowed_transitive_hosts: Vec::new(),
..ModulesConfig::default()
})
.unwrap();
assert_eq!(
open.credential_mode(DependencyScope::Transitive, Some("github.com")),
CredentialMode::Disabled
);
}
#[test]
fn credentials_can_be_disabled_for_every_scope() {
let policy = ResolverPolicy::default().without_credentials();
assert_eq!(
policy.credential_mode(DependencyScope::TopLevel, Some("github.com")),
CredentialMode::Disabled
);
assert_eq!(
policy.credential_mode(DependencyScope::Transitive, Some("github.com")),
CredentialMode::Disabled
);
}
#[test]
fn empty_address_list_is_rejected() {
assert!(validate_resolved_addresses(&[]).is_err());
}
#[test]
fn loopback_address_is_rejected() {
let addr: std::net::SocketAddr = "127.0.0.1:443".parse().unwrap();
let result = validate_resolved_addresses(&[addr]);
assert!(result.is_err());
assert!(result.unwrap_err().is_some());
}
#[test]
fn public_address_is_accepted() {
let addr: std::net::SocketAddr = "140.82.121.3:443".parse().unwrap();
assert!(validate_resolved_addresses(&[addr]).is_ok());
}
#[test]
fn dns_failure_rejects_url() {
let policy = ResolverPolicy::default();
let dep = "foo".parse().unwrap();
let url: url::Url = "https://this-host-does-not-exist-xyzzy.invalid/x/y"
.parse()
.unwrap();
let err = policy
.check_git_url(&dep, &url, DependencyScope::TopLevel)
.unwrap_err();
assert!(
matches!(err, ResolverError::GitHostResolutionFailed { .. }),
"expected `GitHostResolutionFailed`, got: {err}"
);
}
}