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::ops::CredentialMode;
use crate::resolver::git::ops::FetchPolicy;
const ALLOWED_HOSTS_CONFIG_KEY: &str = "allowed_hosts";
const ALLOWED_TRANSITIVE_HOSTS_CONFIG_KEY: &str = "allowed_transitive_hosts";
#[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,
pub(crate) max_transfer_bytes: Option<u64>,
}
#[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,
}
})?,
max_transfer_bytes: config.max_transfer_bytes.as_bytes(),
},
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,
}
})?,
max_transfer_bytes: config.max_transfer_bytes.as_bytes(),
},
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 fetch_policy(&self, scope: DependencyScope, url: &Url) -> FetchPolicy {
FetchPolicy {
credentials: self.credential_mode(scope, url),
max_transfer_bytes: self.git_policy(scope).max_transfer_bytes,
}
}
pub(crate) fn credential_mode(&self, scope: DependencyScope, url: &Url) -> CredentialMode {
if !self.credentials_enabled {
return CredentialMode::Disabled;
}
match scope {
DependencyScope::TopLevel => CredentialMode::Enabled,
DependencyScope::Transitive => match normalized_host(url) {
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(),
});
}
let config_key = match scope {
DependencyScope::TopLevel => ALLOWED_HOSTS_CONFIG_KEY,
DependencyScope::Transitive => ALLOWED_TRANSITIVE_HOSTS_CONFIG_KEY,
};
if let Some(host) = url.host() {
match host {
url::Host::Domain(host) => {
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 !net.host_policy.allows(host) {
return Err(ResolverError::GitHostNotAllowed {
dep: name.manifest().to_string(),
url: url.to_string(),
host: host.to_string(),
config_key,
});
}
if 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(),
}),
};
}
}
}
url::Host::Ipv4(host) => {
let host = host.to_string();
if self
.denied_hosts
.iter()
.any(|denied| denied.eq_ignore_ascii_case(&host))
|| super::config::is_non_public_ip(&host)
{
return Err(ResolverError::GitHostPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
host,
});
}
if !net.host_policy.allows(&host) {
return Err(ResolverError::GitHostNotAllowed {
dep: name.manifest().to_string(),
url: url.to_string(),
host,
config_key,
});
}
}
url::Host::Ipv6(host) => {
let host = host.to_string();
let display = format!("[{host}]");
if self
.denied_hosts
.iter()
.any(|denied| denied.eq_ignore_ascii_case(&host))
|| super::config::is_non_public_ip(&host)
{
return Err(ResolverError::GitHostPolicyViolation {
dep: name.manifest().to_string(),
url: url.to_string(),
host: display,
});
}
if !net.host_policy.allows(&host) {
return Err(ResolverError::GitHostNotAllowed {
dep: name.manifest().to_string(),
url: url.to_string(),
host: display,
config_key,
});
}
}
}
}
Ok(())
}
}
fn normalized_host(url: &Url) -> Option<String> {
match url.host()? {
url::Host::Domain(host) => Some(host.to_string()),
url::Host::Ipv4(host) => Some(host.to_string()),
url::Host::Ipv6(host) => Some(host.to_string()),
}
}
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::dependency::DependencyName;
use crate::resolver::config::ModulesConfig;
use crate::resolver::error::ResolverError;
fn dependency() -> DependencyName {
"dep".parse().unwrap()
}
fn url(source: &str) -> Url {
source.parse().unwrap()
}
#[test]
fn blocks_file_scheme() {
let policy = ResolverPolicy::default();
let dep = dependency();
let url = url("file:///repo");
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 = dependency();
let url = url("ssh://git@github.com/x/y");
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 = dependency();
let url = url("https://github.com/x/y");
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,
&url("https://github.com/org/repository.git")
),
CredentialMode::Enabled
);
assert_eq!(
default_policy.credential_mode(
DependencyScope::Transitive,
&url("https://bitbucket.org/org/repository.git")
),
CredentialMode::Disabled
);
assert_eq!(
default_policy.credential_mode(
DependencyScope::TopLevel,
&url("https://bitbucket.org/org/repository.git")
),
CredentialMode::Enabled
);
let open = ResolverPolicy::try_from(&ModulesConfig {
allowed_transitive_hosts: Vec::new(),
..ModulesConfig::default()
})
.unwrap();
assert_eq!(
open.credential_mode(
DependencyScope::Transitive,
&url("https://github.com/org/repository.git")
),
CredentialMode::Disabled
);
}
#[test]
fn rejects_loopback_and_private_hosts_through_public_policy_boundary() {
let policy = ResolverPolicy::default();
let dep = dependency();
for (scope, source) in [
(
DependencyScope::TopLevel,
"https://localhost/repository.git",
),
(
DependencyScope::Transitive,
"https://127.0.0.1/repository.git",
),
(DependencyScope::TopLevel, "https://0.0.0.0/repository.git"),
] {
let error = policy
.check_git_url(&dep, &url(source), scope)
.expect_err("loopback and private hosts must be rejected");
assert!(
matches!(error, ResolverError::GitHostPolicyViolation { .. }),
"got: {error}"
);
}
}
#[test]
fn default_policy_denies_all_non_public_literal_hosts() {
let policy = ResolverPolicy::default();
let dep = dependency();
let denied = [
"https://169.254.169.254/repository.git",
"https://10.0.0.1/repository.git",
"https://192.168.1.1/repository.git",
"https://172.16.0.1/repository.git",
"https://100.64.0.1/repository.git",
"https://127.0.0.1/repository.git",
"https://0.0.0.0/repository.git",
"https://255.255.255.255/repository.git",
"https://224.0.0.1/repository.git",
"https://[::1]/repository.git",
"https://[::]/repository.git",
"https://[fe80::1]/repository.git",
"https://[fc00::1]/repository.git",
"https://[ff02::1]/repository.git",
"https://[::ffff:127.0.0.1]/repository.git",
"https://[::ffff:169.254.169.254]/repository.git",
"https://[::ffff:10.0.0.1]/repository.git",
"https://[::ffff:192.168.1.1]/repository.git",
];
for source in denied {
for scope in [DependencyScope::TopLevel, DependencyScope::Transitive] {
let error = policy
.check_git_url(&dep, &url(source), scope)
.expect_err("non-public literal hosts must be rejected");
assert!(
matches!(error, ResolverError::GitHostPolicyViolation { .. }),
"`{source}` should be rejected by host policy; got: {error}"
);
assert!(
!error.to_string().contains("configured allow list"),
"non-public literal errors must not suggest allowlisting: {error}"
);
}
}
}
#[test]
fn default_policy_allows_public_literal_hosts() {
let policy = ResolverPolicy::default();
let dep = dependency();
for source in [
"https://140.82.121.3/repository.git",
"https://[2606:4700:4700::1111]/repository.git",
"https://[::ffff:140.82.121.3]/repository.git",
] {
policy
.check_git_url(&dep, &url(source), DependencyScope::TopLevel)
.expect("public literal hosts should be allowed");
}
}
#[test]
fn rejects_non_public_ipv6_literal_hosts_before_dns() {
let policy = ResolverPolicy::default();
let dep = dependency();
for source in [
"https://[::1]/repository.git",
"https://[::]/repository.git",
"https://[fd00::1]/repository.git",
"https://[::ffff:127.0.0.1]/repository.git",
"https://[::ffff:169.254.169.254]/repository.git",
"https://[::ffff:10.0.0.1]/repository.git",
] {
let error = policy
.check_git_url(&dep, &url(source), DependencyScope::TopLevel)
.expect_err("non-public IPv6 literal hosts must be rejected");
assert!(
matches!(error, ResolverError::GitHostPolicyViolation { .. }),
"got: {error}"
);
}
}
#[test]
fn allows_public_ipv4_mapped_ipv6_host() {
let policy = ResolverPolicy::default();
let dep = dependency();
policy
.check_git_url(
&dep,
&url("https://[::ffff:140.82.121.3]/repository.git"),
DependencyScope::TopLevel,
)
.expect("configured public host should be allowed");
}
#[test]
fn allows_public_ipv4_host() {
let policy = ResolverPolicy::default();
let dep = dependency();
policy
.check_git_url(
&dep,
&url("https://140.82.121.3/repository.git"),
DependencyScope::TopLevel,
)
.expect("configured public host should be allowed");
}
#[test]
fn allowlists_apply_per_scope_for_complete_urls() {
let policy = ResolverPolicy::try_from(&ModulesConfig {
allowed_hosts: vec!["github.com".into()],
allowed_transitive_hosts: vec!["gitlab.com".into()],
..ModulesConfig::default()
})
.unwrap();
let dep = dependency();
policy
.check_git_url(
&dep,
&url("https://github.com/org/repository.git"),
DependencyScope::TopLevel,
)
.expect("configured host should be allowed");
policy
.check_git_url(
&dep,
&url("https://gitlab.com/org/repository.git"),
DependencyScope::Transitive,
)
.expect("configured host should be allowed");
let error = policy
.check_git_url(
&dep,
&url("https://github.com/org/repository.git"),
DependencyScope::Transitive,
)
.expect_err("top-level allowlist should not apply to transitive dependencies");
assert!(
matches!(
error,
ResolverError::GitHostNotAllowed {
config_key: "allowed_transitive_hosts",
..
}
),
"got: {error}"
);
let error = policy
.check_git_url(
&dep,
&url("https://gitlab.com/org/repository.git"),
DependencyScope::TopLevel,
)
.expect_err("transitive allowlist should not apply to top-level dependencies");
assert!(
matches!(
error,
ResolverError::GitHostNotAllowed {
config_key: "allowed_hosts",
..
}
),
"got: {error}"
);
assert!(
error.to_string().contains("to allow it, add"),
"ordinary denied hosts should retain config guidance: {error}"
);
}
#[test]
fn credentials_can_be_disabled_for_every_scope() {
let policy = ResolverPolicy::default().without_credentials();
assert_eq!(
policy.credential_mode(
DependencyScope::TopLevel,
&url("https://github.com/org/repository.git")
),
CredentialMode::Disabled
);
assert_eq!(
policy.credential_mode(
DependencyScope::Transitive,
&url("https://github.com/org/repository.git")
),
CredentialMode::Disabled
);
}
#[test]
fn transitive_ipv6_literal_credentials_use_normalized_host() {
let policy = ResolverPolicy::try_from(&ModulesConfig {
allowed_transitive_hosts: vec!["2606:4700:4700::1111".into()],
..ModulesConfig::default()
})
.unwrap();
let dep = dependency();
let url = url("https://[2606:4700:4700::1111]/repository.git");
policy
.check_git_url(&dep, &url, DependencyScope::Transitive)
.expect("allowlisted IPv6 literal should pass host policy");
assert_eq!(
policy.credential_mode(DependencyScope::Transitive, &url),
CredentialMode::Enabled
);
}
#[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 = dependency();
let url = url("https://this-host-does-not-exist-xyzzy.invalid/x/y");
let err = policy
.check_git_url(&dep, &url, DependencyScope::TopLevel)
.unwrap_err();
assert!(
matches!(err, ResolverError::GitHostResolutionFailed { .. }),
"expected `GitHostResolutionFailed`, got: {err}"
);
}
}