use std::path::PathBuf;
use std::str::FromStr;
use schemars::JsonSchema;
use thiserror::Error;
use toml_spanner::Toml;
use toml_spanner::helper::display;
use toml_spanner::helper::parse_string;
#[derive(Clone, Debug, PartialEq, Eq, Toml, JsonSchema)]
#[toml(Toml, deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ModulesConfig {
pub cache_path: Option<PathBuf>,
#[toml(default)]
#[schemars(default)]
pub default_git_platform: GitPlatform,
#[toml(default, FromToml with = parse_string, ToToml with = display)]
#[schemars(default)]
pub large_file_warning: LargeFileWarning,
#[toml(default, FromToml with = parse_string, ToToml with = display)]
#[schemars(default)]
pub max_transfer_bytes: TransferLimit,
#[toml(default)]
#[schemars(default)]
pub require_signed: bool,
#[toml(default)]
#[schemars(default)]
pub trust_mode: TrustMode,
#[toml(default = default_top_level_schemes())]
#[schemars(default = "default_top_level_schemes")]
pub allowed_schemes: Vec<String>,
#[toml(default = default_transitive_schemes())]
#[schemars(default = "default_transitive_schemes")]
pub allowed_transitive_schemes: Vec<String>,
#[toml(default = default_max_refs())]
#[schemars(default = "default_max_refs")]
pub max_advertised_refs: u64,
#[toml(default = default_denied_hosts())]
#[schemars(default = "default_denied_hosts")]
pub denied_hosts: Vec<String>,
#[toml(default)]
#[schemars(default)]
pub allowed_hosts: Vec<String>,
#[toml(default = default_allowed_transitive_hosts())]
#[schemars(default = "default_allowed_transitive_hosts")]
pub allowed_transitive_hosts: Vec<String>,
pub max_materialized_files: Option<u64>,
pub max_materialized_bytes: Option<u64>,
}
const fn default_max_refs() -> u64 {
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,
default_git_platform: GitPlatform::default(),
large_file_warning: LargeFileWarning::default(),
max_transfer_bytes: TransferLimit::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,
}
}
}
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, Default, PartialEq, Eq, Toml, JsonSchema)]
#[toml(Toml, rename_all = "lowercase")]
#[schemars(rename_all = "lowercase")]
pub enum GitPlatform {
#[default]
Github,
Gitlab,
Bitbucket,
}
impl GitPlatform {
pub fn expand_shorthand(self, source: &str) -> Option<Result<url::Url, url::ParseError>> {
let shorthand = source.parse::<HostedGitShorthand>().ok()?;
Some(self.repository_url(&shorthand.owner, &shorthand.repo))
}
pub fn shorthand_repo_name(source: &str) -> Option<String> {
let shorthand = source.parse::<HostedGitShorthand>().ok()?;
Some(
shorthand
.repo
.strip_suffix(".git")
.unwrap_or(&shorthand.repo)
.to_string(),
)
}
fn repository_url(self, owner: &str, repo: &str) -> Result<url::Url, url::ParseError> {
let url = format!(
"https://{host}/{owner}/{repo}.git",
host = self.host(),
repo = repo.strip_suffix(".git").unwrap_or(repo)
);
url.parse()
}
fn host(self) -> &'static str {
match self {
Self::Github => "github.com",
Self::Gitlab => "gitlab.com",
Self::Bitbucket => "bitbucket.org",
}
}
}
impl FromStr for GitPlatform {
type Err = GitPlatformError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"github" => Ok(Self::Github),
"gitlab" => Ok(Self::Gitlab),
"bitbucket" => Ok(Self::Bitbucket),
_ => Err(GitPlatformError(s.to_string())),
}
}
}
#[derive(Debug, Error)]
#[error("`{0}` is not a valid git platform (expected `github`, `gitlab`, or `bitbucket`)")]
pub struct GitPlatformError(String);
struct HostedGitShorthand {
owner: String,
repo: String,
}
impl FromStr for HostedGitShorthand {
type Err = HostedGitShorthandError;
fn from_str(source: &str) -> Result<Self, Self::Err> {
let mut parts = source.split('/');
let owner = parts.next().ok_or(HostedGitShorthandError)?;
let repo = parts.next().ok_or(HostedGitShorthandError)?;
if parts.next().is_some()
|| owner.is_empty()
|| repo.is_empty()
|| source.starts_with('.')
|| source.starts_with('/')
|| owner == "."
|| owner == ".."
|| repo == "."
|| repo == ".."
{
return Err(HostedGitShorthandError);
}
Ok(Self {
owner: owner.to_string(),
repo: repo.to_string(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HostedGitShorthandError;
#[derive(Debug, JsonSchema)]
#[schemars(untagged, inline)]
#[expect(dead_code, reason = "Only used for schema generation.")]
enum ByteSizeSchema {
Bytes(u64),
String(#[schemars(pattern(r"^\d+(?:\.\d+)?\s*(?:[KMGTPEkmgtpe][Ii]?[Bb]?|[Bb])$"))] String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
pub enum LargeFileWarning {
#[schemars(rename = "none")]
Disabled,
#[schemars(with = "ByteSizeSchema", untagged)]
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, JsonSchema)]
#[schemars(untagged, inline)]
#[expect(dead_code, reason = "Only used for schema generation.")]
enum TransferLimitSchema {
Unlimited(#[schemars(pattern(r"^[Uu][Nn][Ll][Ii][Mm][Ii][Tt][Ee][Dd]$"))] String),
Bytes(#[schemars(pattern(r"^\d+(?:\.\d+)?\s*(?:[KMGTPEkmgtpe][Ii]?[Bb]?|[Bb])$"))] String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
#[schemars(with = "TransferLimitSchema")]
pub enum TransferLimit {
Unlimited,
Bytes(u64),
}
impl Default for TransferLimit {
fn default() -> Self {
Self::Bytes(2 * 1024 * 1024 * 1024)
}
}
impl TransferLimit {
pub(crate) fn as_bytes(self) -> Option<u64> {
match self {
Self::Unlimited => None,
Self::Bytes(bytes) => Some(bytes),
}
}
}
impl FromStr for TransferLimit {
type Err = TransferLimitError;
fn from_str(source: &str) -> Result<Self, Self::Err> {
if source.eq_ignore_ascii_case("unlimited") {
return Ok(Self::Unlimited);
}
let bytes = source
.parse::<bytesize::ByteSize>()
.map_err(|_| TransferLimitError(source.to_string()))?
.as_u64();
Ok(Self::Bytes(bytes))
}
}
impl std::fmt::Display for TransferLimit {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unlimited => formatter.write_str("unlimited"),
Self::Bytes(bytes) => write!(formatter, "{}", bytesize::ByteSize(*bytes)),
}
}
}
#[derive(Debug, Error)]
#[error("`{0}` is not a valid transfer limit (expected e.g. `2GiB`, `500MB`, or `unlimited`)")]
pub struct TransferLimitError(String);
#[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, PartialEq, Eq, Toml, JsonSchema)]
#[toml(Toml, rename_all = "kebab-case")]
#[schemars(rename_all = "kebab-case")]
pub enum TrustMode {
AutoAccept,
Tofu,
#[default]
Confirm,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_threshold_when_absent() {
let cfg: ModulesConfig = toml_spanner::from_str("").unwrap();
assert!(matches!(
cfg.large_file_warning,
LargeFileWarning::Threshold(b) if b == 1024 * 1024
));
}
#[test]
fn parses_default_git_platform() {
let cfg: ModulesConfig =
toml_spanner::from_str(r#"default_git_platform = "gitlab""#).unwrap();
assert_eq!(cfg.default_git_platform, GitPlatform::Gitlab);
}
#[test]
fn parses_trust_modes() {
let cfg: ModulesConfig = toml_spanner::from_str(r#"trust_mode = "confirm""#).unwrap();
assert_eq!(cfg.trust_mode, TrustMode::Confirm);
let cfg: ModulesConfig = toml_spanner::from_str(r#"trust_mode = "auto-accept""#).unwrap();
assert_eq!(cfg.trust_mode, TrustMode::AutoAccept);
assert!(toml_spanner::from_str::<ModulesConfig>(r#"trust_mode = "auto""#).is_err());
let cfg: ModulesConfig = toml_spanner::from_str(r#"trust_mode = "tofu""#).unwrap();
assert_eq!(cfg.trust_mode, TrustMode::Tofu);
}
#[test]
fn expands_hosted_git_shorthand() {
let url = GitPlatform::Bitbucket
.expand_shorthand("stjudecloud/workflows.git")
.and_then(Result::ok);
assert_eq!(
url.as_ref().map(url::Url::as_str),
Some("https://bitbucket.org/stjudecloud/workflows.git")
);
assert_eq!(
GitPlatform::shorthand_repo_name("stjudecloud/workflows.git").as_deref(),
Some("workflows")
);
assert!(
GitPlatform::Github
.expand_shorthand("./stjudecloud/workflows")
.is_none()
);
}
#[test]
fn parses_size_string() {
let cfg: ModulesConfig = toml_spanner::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_spanner::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_spanner::from_str::<ModulesConfig>(r#"large_file_warning = "abc""#).unwrap_err();
assert!(err.to_string().contains("abc"), "wrong message: {err}");
}
#[test]
fn transfer_limit_round_trips() {
assert_eq!(
"unlimited".parse::<TransferLimit>().unwrap(),
TransferLimit::Unlimited
);
assert_eq!(TransferLimit::Unlimited.as_bytes(), None);
assert_eq!(
"2GiB".parse::<TransferLimit>().unwrap(),
TransferLimit::Bytes(2_147_483_648)
);
assert_eq!(TransferLimit::default().to_string(), "2.0 GiB");
assert!("abc".parse::<TransferLimit>().is_err());
}
#[test]
fn parses_transfer_limit_from_toml() {
let cfg: ModulesConfig = toml_spanner::from_str(r#"max_transfer_bytes = "2GiB""#).unwrap();
assert_eq!(cfg.max_transfer_bytes, TransferLimit::Bytes(2_147_483_648));
let cfg: ModulesConfig =
toml_spanner::from_str(r#"max_transfer_bytes = "UnLiMiTeD""#).unwrap();
assert_eq!(cfg.max_transfer_bytes, TransferLimit::Unlimited);
assert!(
toml_spanner::from_str::<ModulesConfig>("max_transfer_bytes = 2147483648").is_err()
);
}
}