use crate::error::ForgeError;
#[derive(Debug, Clone)]
pub struct ForgeConfig {
pub prefix: String,
pub token_ttl_secs: u64,
pub max_body_bytes: usize,
pub fetch_concurrency: usize,
pub fetch_timeout_secs: u64,
pub thread_cap: usize,
pub default_chain: String,
pub allow_mainnet: bool,
pub announce_relays: Vec<String>,
pub announce_key: Option<AnnounceKey>,
pub anchoring_ui: bool,
pub sparse_marks: bool,
pub private_repos: bool,
pub base_url: Option<String>,
pub loopback_url: Option<String>,
pub csp_connect: Vec<String>,
pub open_edit: bool,
}
#[derive(Clone)]
pub struct AnnounceKey(String);
impl AnnounceKey {
#[must_use]
pub fn new(hex: impl Into<String>) -> Self {
Self(hex.into())
}
#[must_use]
pub fn valid_hex(&self) -> Option<String> {
let lc = self.0.to_ascii_lowercase();
(lc.len() == 64 && lc.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))).then_some(lc)
}
}
impl std::fmt::Debug for AnnounceKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AnnounceKey(<redacted>)")
}
}
impl Default for ForgeConfig {
fn default() -> Self {
Self {
prefix: "/forge".to_string(),
token_ttl_secs: 3600,
max_body_bytes: 64 * 1024,
fetch_concurrency: 8,
fetch_timeout_secs: 8,
thread_cap: 500,
default_chain: "tbtc4".to_string(),
allow_mainnet: false,
announce_relays: Vec::new(),
announce_key: None,
anchoring_ui: false,
sparse_marks: false,
private_repos: false,
base_url: None,
loopback_url: None,
open_edit: false,
csp_connect: Vec::new(),
}
}
}
const MAINNET_ALIASES: &[&str] = &["btc", "bitcoin", "mainnet", "xbt", "main"];
#[must_use]
pub fn chain_hrp(chain: &str) -> Option<&'static str> {
match chain.to_ascii_lowercase().as_str() {
"btc" | "mainnet" | "bitcoin" | "xbt" => Some("bc"),
"tbtc4" | "tbtc3" | "signet" | "txbt4" => Some("tb"),
"regtest" => Some("bcrt"),
_ => None,
}
}
pub const KNOWN_CHAINS: &[&str] = &[
"btc", "mainnet", "bitcoin", "tbtc4", "tbtc3", "signet", "regtest", "txbt4", "xbt",
];
impl ForgeConfig {
#[must_use]
pub fn normalized_prefix(&self) -> String {
let trimmed = self.prefix.trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
#[must_use]
pub fn chain_allowed(&self, chain: &str) -> bool {
let lc = chain.to_ascii_lowercase();
if MAINNET_ALIASES.contains(&lc.as_str()) {
self.allow_mainnet
} else {
true
}
}
pub fn validate_chain(&self) -> Result<(), ForgeError> {
let chain = &self.default_chain;
if !self.chain_allowed(chain) {
return Err(ForgeError::BadRequest(format!(
"forge: config.chain \"{chain}\" is Bitcoin MAINNET: refusing to derive mainnet \
anchor addresses. The forge holds the trail key server-side (testnet posture) and never \
verifies or broadcasts; set allow_mainnet only if you accept that custody"
)));
}
if chain_hrp(chain).is_none() {
return Err(ForgeError::BadRequest(format!(
"forge: unknown config.chain \"{chain}\" (supported: {})",
KNOWN_CHAINS.join(", ")
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_testnet_and_bounded() {
let c = ForgeConfig::default();
assert_eq!(c.prefix, "/forge");
assert_eq!(c.default_chain, "tbtc4");
assert!(!c.allow_mainnet);
assert_eq!(c.max_body_bytes, 64 * 1024);
assert_eq!(c.thread_cap, 500);
assert_eq!(c.fetch_concurrency, 8);
}
#[test]
fn mainnet_guard_fails_closed() {
let c = ForgeConfig::default();
for m in ["btc", "BTC", "Bitcoin", "mainnet", "main", "xbt", "XBT"] {
assert!(!c.chain_allowed(m), "{m} must be refused by default");
}
assert!(c.chain_allowed("tbtc4"));
assert!(c.chain_allowed("testnet"));
assert!(c.chain_allowed("signet"));
}
#[test]
fn mainnet_guard_opens_when_allowed() {
let c = ForgeConfig {
allow_mainnet: true,
..Default::default()
};
assert!(c.chain_allowed("btc"));
assert!(c.chain_allowed("mainnet"));
}
#[test]
fn validate_chain_refuses_mainnet_and_unknown_like_jss_activate() {
for chain in ["btc", "mainnet", "bitcoin", "xbt"] {
let c = ForgeConfig {
default_chain: chain.into(),
..Default::default()
};
let e = c.validate_chain().unwrap_err().to_string();
assert!(e.contains("MAINNET"), "{e}");
assert!(e.contains("allow_mainnet"), "{e}");
}
let unknown = ForgeConfig {
default_chain: "dogecoin".into(),
..Default::default()
};
assert!(unknown
.validate_chain()
.unwrap_err()
.to_string()
.contains("unknown config.chain"));
let open = ForgeConfig {
default_chain: "btc".into(),
allow_mainnet: true,
..Default::default()
};
assert!(open.validate_chain().is_ok());
for chain in ["tbtc4", "tbtc3", "signet", "regtest", "txbt4"] {
let c = ForgeConfig {
default_chain: chain.into(),
..Default::default()
};
assert!(c.validate_chain().is_ok(), "{chain}");
}
}
#[test]
fn chain_hrp_table_matches_jss() {
assert_eq!(chain_hrp("btc"), Some("bc"));
assert_eq!(chain_hrp("xbt"), Some("bc"));
assert_eq!(chain_hrp("txbt4"), Some("tb"));
assert_eq!(chain_hrp("regtest"), Some("bcrt"));
assert_eq!(chain_hrp("testnet"), None);
}
#[test]
fn new_anchoring_keys_default_off() {
let c = ForgeConfig::default();
assert!(!c.anchoring_ui);
assert!(!c.sparse_marks);
}
#[test]
fn prefix_normalisation() {
let mk = |p: &str| ForgeConfig {
prefix: p.to_string(),
..Default::default()
};
assert_eq!(mk("/forge").normalized_prefix(), "/forge");
assert_eq!(mk("forge").normalized_prefix(), "/forge");
assert_eq!(mk("/forge/").normalized_prefix(), "/forge");
assert_eq!(mk("/git/forge/").normalized_prefix(), "/git/forge");
assert_eq!(mk("/").normalized_prefix(), "");
assert_eq!(mk("").normalized_prefix(), "");
}
}