Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 145 additions & 6 deletions ic-bn-lib/src/custom_domains/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::{
fmt::{self, Debug},
path::PathBuf,
str::FromStr,
sync::{
Arc, Mutex,
Expand Down Expand Up @@ -103,8 +105,8 @@ pub struct GenericProvider {
timeout: Duration,
}

impl std::fmt::Debug for GenericProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl Debug for GenericProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "GenericProvider({})", self.url)
}
}
Expand Down Expand Up @@ -136,8 +138,8 @@ pub struct GenericProviderTimestamped {
cache: ArcSwapOption<Vec<CustomDomain>>,
}

impl std::fmt::Debug for GenericProviderTimestamped {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl Debug for GenericProviderTimestamped {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "GenericProviderTimestamped({})", self.url)
}
}
Expand Down Expand Up @@ -282,8 +284,8 @@ impl GenericProviderDiff {
}
}

impl std::fmt::Debug for GenericProviderDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl Debug for GenericProviderDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "GenericProviderDiff({})", self.url)
}
}
Expand Down Expand Up @@ -329,6 +331,74 @@ impl ProvidesCustomDomains for GenericProviderDiff {
}
}

/// Local file-based custom domain provider.
///
/// Reads domain-to-canister mappings from a file with one mapping per line in the format `domain:principal`.
/// Empty lines are ignored, and whitespace is trimmed.
///
/// # Example File Format
///
/// ```text
/// example.com:aaaaa-aa
/// test.org:qoctq-giaaa-aaaaa-aaaea-cai
/// my-domain.net:ryjl3-tyaaa-aaaaa-aaaba-cai
///
/// another-domain.com:2vxsx-fae
/// ```
#[derive(new)]
pub struct LocalFileProvider {
file_path: PathBuf,
}

impl Debug for LocalFileProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "LocalFileProvider({})", self.file_path.display())
}
}

#[async_trait]
impl ProvidesCustomDomains for LocalFileProvider {
async fn get_custom_domains(&self) -> Result<Vec<CustomDomain>, Error> {
let body = std::fs::read_to_string(&self.file_path).context("unable to read file")?;

let mut domains = Vec::new();
for line in body.lines() {
// Skip empty lines
if line.trim().is_empty() {
continue;
}

// Split by colon
let parts: Vec<&str> = line.split(':').collect();
if parts.len() != 2 {
return Err(anyhow!(
"invalid line format '{}', expected 'domain:principal'",
line
));
}

let domain_str = parts[0].trim();
let principal_str = parts[1].trim();

// Parse domain as FQDN
let name = FQDN::from_str(domain_str)
.with_context(|| format!("unable to parse '{}' as FQDN", domain_str))?;

// Parse principal
let canister_id = Principal::from_text(principal_str)
.with_context(|| format!("unable to parse '{}' as principal", principal_str))?;

domains.push(CustomDomain {
name,
canister_id,
timestamp: 0,
});
}

Ok(domains)
}
}

#[cfg(test)]
mod test {
use ::http::Response as HttpResponse;
Expand Down Expand Up @@ -702,4 +772,73 @@ mod test {
let prov = GenericProvider::new(cli, "http://foo".try_into().unwrap(), Duration::ZERO);
assert!(prov.get_custom_domains().await.is_err());
}

#[tokio::test]
async fn test_local_file_provider_valid() {
use std::io::Write;
let mut temp_file = tempfile::NamedTempFile::new().unwrap();

// Write valid domain:principal pairs
writeln!(temp_file, "foo.bar:aaaaa-aa").unwrap();
writeln!(temp_file, "test.example.com:qoctq-giaaa-aaaaa-aaaea-cai").unwrap();
writeln!(
temp_file,
"2athis-domain-is-exactly-fifty-one-characters-l.com:ryjl3-tyaaa-aaaaa-aaaba-cai"
)
.unwrap();
temp_file.flush().unwrap();

let prov = LocalFileProvider::new(temp_file.path().to_path_buf());
let mut domains: Vec<CustomDomain> = prov.get_custom_domains().await.unwrap();
domains.sort_by(|a, b| a.name.cmp(&b.name));

assert_eq!(domains.len(), 3);
assert_eq!(
domains,
vec![
CustomDomain {
name: fqdn!("foo.bar"),
canister_id: principal!("aaaaa-aa"),
timestamp: 0,
},
CustomDomain {
name: fqdn!("test.example.com"),
canister_id: principal!("qoctq-giaaa-aaaaa-aaaea-cai"),
timestamp: 0,
},
CustomDomain {
name: fqdn!("2athis-domain-is-exactly-fifty-one-characters-l.com"),
canister_id: principal!("ryjl3-tyaaa-aaaaa-aaaba-cai"),
timestamp: 0,
},
]
);
}

#[tokio::test]
async fn test_local_file_provider_missing_colon() {
use std::io::Write;
let mut temp_file = tempfile::NamedTempFile::new().unwrap();

// Write invalid format (no colon)
writeln!(temp_file, "foo.bar aaaaa-aa").unwrap();
temp_file.flush().unwrap();

let prov = LocalFileProvider::new(temp_file.path().to_path_buf());
let result = prov.get_custom_domains().await;

assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("invalid line format"));
}

#[tokio::test]
async fn test_local_file_provider_file_not_found() {
let prov = LocalFileProvider::new("/nonexistent/path/to/file.txt".into());
let result = prov.get_custom_domains().await;

assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("unable to read file"));
}
}