use std::path::PathBuf;
use std::str::FromStr;
use serde::Deserialize;
use serde::Serialize;
use serde_with::DeserializeFromStr;
use serde_with::SerializeDisplay;
use thiserror::Error;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ModulesConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_path: Option<PathBuf>,
pub large_file_warning: LargeFileWarning,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub require_signed: bool,
pub trust_mode: TrustMode,
#[serde(
default = "default_top_level_schemes",
skip_serializing_if = "Vec::is_empty"
)]
pub allowed_schemes: Vec<String>,
#[serde(
default = "default_transitive_schemes",
skip_serializing_if = "Vec::is_empty"
)]
pub allowed_transitive_schemes: Vec<String>,
#[serde(default = "default_max_refs")]
pub max_advertised_refs: usize,
#[serde(
default = "default_denied_hosts",
skip_serializing_if = "Vec::is_empty"
)]
pub denied_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_hosts: Vec<String>,
#[serde(
default = "default_allowed_transitive_hosts",
skip_serializing_if = "Vec::is_empty"
)]
pub allowed_transitive_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_materialized_files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_materialized_bytes: Option<u64>,
}
fn default_max_refs() -> usize {
100_000
}
fn default_top_level_schemes() -> Vec<String> {
vec!["https".into(), "ssh".into()]
}
fn default_transitive_schemes() -> Vec<String> {
vec!["https".into()]
}
fn default_allowed_transitive_hosts() -> Vec<String> {
vec!["github.com".into(), "gitlab.com".into()]
}
fn default_denied_hosts() -> Vec<String> {
vec![
"localhost".into(),
"127.0.0.1".into(),
"::1".into(),
"0.0.0.0".into(),
]
}
impl Default for ModulesConfig {
fn default() -> Self {
Self {
cache_path: None,
large_file_warning: LargeFileWarning::default(),
require_signed: false,
trust_mode: TrustMode::default(),
allowed_schemes: default_top_level_schemes(),
allowed_transitive_schemes: default_transitive_schemes(),
max_advertised_refs: default_max_refs(),
denied_hosts: default_denied_hosts(),
allowed_hosts: Vec::new(),
allowed_transitive_hosts: default_allowed_transitive_hosts(),
max_materialized_files: None,
max_materialized_bytes: None,
}
}
}
#[cfg(test)]
use crate::resolver::DependencyScope;
#[cfg(test)]
impl ModulesConfig {
fn host_allowed(&self, host: &str, scope: DependencyScope) -> bool {
if self
.denied_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(host))
{
return false;
}
if is_non_public_ip(host) {
return false;
}
let allowed = if matches!(scope, DependencyScope::Transitive) {
&self.allowed_transitive_hosts
} else {
&self.allowed_hosts
};
allowed.is_empty() || allowed.iter().any(|h| h.eq_ignore_ascii_case(host))
}
}
pub(crate) fn is_non_public_ip(host: &str) -> bool {
use std::net::IpAddr;
let Ok(ip) = host.parse::<IpAddr>() else {
return false;
};
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64
}
IpAddr::V6(v6) => {
if let Some(mapped) = v6.to_ipv4_mapped() {
return is_non_public_ip(&mapped.to_string());
}
v6.is_loopback()
|| v6.is_multicast()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xFE00) == 0xFC00
|| (v6.segments()[0] & 0xFFC0) == 0xFE80
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
pub enum LargeFileWarning {
Disabled,
Threshold(u64),
}
impl Default for LargeFileWarning {
fn default() -> Self {
Self::Threshold(1024 * 1024)
}
}
impl FromStr for LargeFileWarning {
type Err = LargeFileWarningError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("none") {
return Ok(Self::Disabled);
}
let bytes = s
.parse::<bytesize::ByteSize>()
.map_err(|_| LargeFileWarningError(s.to_string()))?
.as_u64();
Ok(Self::Threshold(bytes))
}
}
impl std::fmt::Display for LargeFileWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LargeFileWarning::Disabled => f.write_str("none"),
LargeFileWarning::Threshold(b) => write!(f, "{}", bytesize::ByteSize(*b)),
}
}
}
#[derive(Debug, Error)]
#[error("`{0}` is not a valid file-size string (expected e.g. `1MiB`, `500KB`, or `none`)")]
pub struct LargeFileWarningError(String);
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TrustMode {
#[default]
Auto,
Confirm,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolver::DependencyScope;
#[test]
fn parses_default_threshold_when_absent() {
let cfg: ModulesConfig = toml::from_str("").unwrap();
assert!(matches!(
cfg.large_file_warning,
LargeFileWarning::Threshold(b) if b == 1024 * 1024
));
}
#[test]
fn parses_size_string() {
let cfg: ModulesConfig = toml::from_str(r#"large_file_warning = "5MiB""#).unwrap();
assert!(matches!(
cfg.large_file_warning,
LargeFileWarning::Threshold(b) if b == 5 * 1024 * 1024
));
}
#[test]
fn parses_none_sentinel() {
for s in ["none", "NONE", "None"] {
let cfg: ModulesConfig =
toml::from_str(&format!(r#"large_file_warning = "{s}""#)).unwrap();
assert!(matches!(cfg.large_file_warning, LargeFileWarning::Disabled));
}
}
#[test]
fn rejects_invalid_size_string() {
let err = toml::from_str::<ModulesConfig>(r#"large_file_warning = "abc""#).unwrap_err();
assert!(err.to_string().contains("abc"), "wrong message: {err}");
}
#[test]
fn default_policy_denies_localhost_hosts() {
let cfg = ModulesConfig::default();
assert!(!cfg.host_allowed("localhost", DependencyScope::TopLevel));
assert!(!cfg.host_allowed("127.0.0.1", DependencyScope::Transitive));
assert!(!cfg.host_allowed("::1", DependencyScope::Transitive));
assert!(!cfg.host_allowed("0.0.0.0", DependencyScope::TopLevel));
}
#[test]
fn default_policy_denies_private_and_metadata_ips() {
let cfg = ModulesConfig::default();
let denied = [
"169.254.169.254",
"10.0.0.1",
"192.168.1.1",
"172.16.0.1",
"100.64.0.1",
"127.0.0.1",
"0.0.0.0",
"255.255.255.255",
"224.0.0.1",
"::1",
"::",
"fe80::1",
"fc00::1",
"ff02::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"::ffff:10.0.0.1",
"::ffff:192.168.1.1",
];
for ip in denied {
for scope in [DependencyScope::TopLevel, DependencyScope::Transitive] {
assert!(
!cfg.host_allowed(ip, scope),
"`{ip}` should be denied for `{scope:?}`"
);
}
}
}
#[test]
fn default_policy_allows_public_hosts() {
let cfg = ModulesConfig::default();
assert!(cfg.host_allowed("github.com", DependencyScope::TopLevel));
assert!(cfg.host_allowed("github.com", DependencyScope::Transitive));
assert!(
cfg.host_allowed("::ffff:140.82.121.3", DependencyScope::TopLevel),
"public IPv4-mapped IPv6 should be allowed"
);
}
#[test]
fn allowlist_limits_transitive_hosts() {
let cfg = ModulesConfig {
allowed_transitive_hosts: vec!["github.com".into()],
..ModulesConfig::default()
};
assert!(cfg.host_allowed("github.com", DependencyScope::Transitive));
assert!(!cfg.host_allowed("gitlab.com", DependencyScope::Transitive));
assert!(cfg.host_allowed("gitlab.com", DependencyScope::TopLevel));
}
}