use std::fs::OpenOptions;
use std::io::Read;
use std::path::{Path, PathBuf};
use cellos_core::CellosError;
pub(crate) const TRUST_ANCHORS_MAX_BYTES: u64 = 32 * 1024;
pub const TRUST_ANCHOR_SOURCE_IANA_DEFAULT: &str = "iana-default";
pub const ENV_TRUST_ANCHORS_PATH: &str = "CELLOS_DNSSEC_TRUST_ANCHORS_PATH";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrustAnchors {
pub bytes: Vec<u8>,
pub source: String,
path: Option<PathBuf>,
}
impl TrustAnchors {
pub fn load(spec_path: Option<&str>) -> Result<Self, CellosError> {
let env_path: Option<String> = match std::env::var(ENV_TRUST_ANCHORS_PATH) {
Ok(v) if !v.is_empty() => Some(v),
_ => None,
};
let chosen: Option<PathBuf> = match (env_path.as_deref(), spec_path) {
(Some(env), _) => Some(PathBuf::from(env)),
(None, Some(spec)) if !spec.is_empty() => Some(PathBuf::from(spec)),
_ => None,
};
let Some(path) = chosen else {
return Ok(Self::iana_default());
};
Self::load_from_path(&path)
}
pub fn load_from_path(path: &Path) -> Result<Self, CellosError> {
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
#[cfg(target_os = "linux")]
const O_NOFOLLOW: i32 = 0x20000;
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
))]
const O_NOFOLLOW: i32 = 0x100;
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
)))]
compile_error!(
"cellos-supervisor::resolver_refresh::dnssec: O_NOFOLLOW value not yet defined for \
this Unix target — add the platform-specific value (see <fcntl.h>) before building."
);
let mut opts = OpenOptions::new();
opts.read(true).custom_flags(O_NOFOLLOW);
opts.open(path).map_err(|e| {
CellosError::InvalidSpec(format!(
"dnssec trust anchors: cannot open {} (symlink? missing?): {e}",
path.display()
))
})?
};
#[cfg(not(unix))]
let file = OpenOptions::new().read(true).open(path).map_err(|e| {
CellosError::InvalidSpec(format!(
"dnssec trust anchors: cannot open {}: {e}",
path.display()
))
})?;
if let Ok(meta) = file.metadata() {
if meta.len() > TRUST_ANCHORS_MAX_BYTES {
return Err(CellosError::InvalidSpec(format!(
"dnssec trust anchors: file {} is {} bytes, exceeds {} byte ceiling",
path.display(),
meta.len(),
TRUST_ANCHORS_MAX_BYTES
)));
}
}
let mut buf = Vec::with_capacity(TRUST_ANCHORS_MAX_BYTES as usize);
file.take(TRUST_ANCHORS_MAX_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| {
CellosError::InvalidSpec(format!(
"dnssec trust anchors: cannot read {}: {e}",
path.display()
))
})?;
if buf.len() as u64 > TRUST_ANCHORS_MAX_BYTES {
return Err(CellosError::InvalidSpec(format!(
"dnssec trust anchors: file {} streamed {}+ bytes, exceeds {} byte ceiling",
path.display(),
buf.len(),
TRUST_ANCHORS_MAX_BYTES
)));
}
let source = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("path-anchor")
.to_string();
Ok(Self {
bytes: buf,
source,
path: Some(path.to_path_buf()),
})
}
#[must_use]
pub fn iana_default() -> Self {
Self {
bytes: Vec::new(),
source: TRUST_ANCHOR_SOURCE_IANA_DEFAULT.to_string(),
path: None,
}
}
#[must_use]
pub fn is_iana_default(&self) -> bool {
self.bytes.is_empty()
&& self.source == TRUST_ANCHOR_SOURCE_IANA_DEFAULT
&& self.path.is_none()
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
#[cfg(test)]
pub(crate) fn set_path_for_test(&mut self, path: Option<PathBuf>) {
self.path = path;
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
struct EnvGuard {
prior: Option<String>,
}
impl EnvGuard {
fn new() -> Self {
let prior = std::env::var(ENV_TRUST_ANCHORS_PATH).ok();
std::env::remove_var(ENV_TRUST_ANCHORS_PATH);
Self { prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.prior.take() {
Some(v) => std::env::set_var(ENV_TRUST_ANCHORS_PATH, v),
None => std::env::remove_var(ENV_TRUST_ANCHORS_PATH),
}
}
}
#[test]
fn loads_iana_default_when_no_path_set() {
let _guard = EnvGuard::new();
let ta = TrustAnchors::load(None).expect("default load ok");
assert!(
ta.is_iana_default(),
"fall-through must yield IANA default; got source={}",
ta.source
);
assert_eq!(ta.source, TRUST_ANCHOR_SOURCE_IANA_DEFAULT);
assert!(ta.bytes.is_empty());
let ta2 = TrustAnchors::load(Some("")).expect("empty-spec default load ok");
assert!(
ta2.is_iana_default(),
"empty-string spec must fall through to IANA default"
);
}
#[test]
fn loads_path_with_o_nofollow_unix() {
let _guard = EnvGuard::new();
let dir = tempdir().expect("tempdir");
let path = dir.path().join("trust-anchor.bin");
let payload = b"DNSKEY-PUBLIC-KEY-BYTES";
std::fs::write(&path, payload).expect("write anchor bytes");
let ta = TrustAnchors::load_from_path(&path).expect("path-load ok");
assert_eq!(ta.bytes, payload, "bytes must round-trip from disk");
assert_eq!(
ta.source, "trust-anchor.bin",
"source must be the basename, NOT the full path (no fs layout leak)"
);
assert_eq!(
ta.path(),
Some(path.as_path()),
"operator-supplied path must be retained verbatim for ResolverOpts.trust_anchor"
);
assert!(
!ta.is_iana_default(),
"operator-supplied anchor must not be classified as IANA default"
);
std::env::set_var(ENV_TRUST_ANCHORS_PATH, path.to_str().unwrap());
let ta_env = TrustAnchors::load(Some("/some/spec/path-that-should-be-overridden"))
.expect("env-precedence load ok");
assert_eq!(
ta_env.bytes, payload,
"env-set path must win over spec-set path"
);
assert_eq!(ta_env.source, "trust-anchor.bin");
assert_eq!(
ta_env.path(),
Some(path.as_path()),
"env-set path must also be retained for ResolverOpts.trust_anchor"
);
}
#[test]
fn iana_default_sentinel_has_no_path() {
let ta = TrustAnchors::iana_default();
assert!(ta.path().is_none(), "IANA default must have no path");
assert!(ta.is_iana_default());
}
#[test]
fn rejects_oversized_trust_anchors_file() {
let _guard = EnvGuard::new();
let dir = tempdir().expect("tempdir");
let path = dir.path().join("oversize.bin");
let payload = vec![0xABu8; (TRUST_ANCHORS_MAX_BYTES + 1024) as usize];
std::fs::write(&path, &payload).expect("write oversize");
let err = TrustAnchors::load_from_path(&path).expect_err("oversize file must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("exceeds") && msg.contains(&TRUST_ANCHORS_MAX_BYTES.to_string()),
"rejection must mention the size ceiling for operator triage; got {msg}"
);
}
#[cfg(unix)]
#[test]
fn rejects_symlink_at_path() {
let _guard = EnvGuard::new();
let dir = tempdir().expect("tempdir");
let real_path = dir.path().join("real-anchor.bin");
let symlink_path = dir.path().join("symlinked-anchor.bin");
std::fs::write(&real_path, b"REAL-KEY-BYTES").expect("write real");
std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
let err = TrustAnchors::load_from_path(&symlink_path)
.expect_err("symlink at final component MUST be rejected by O_NOFOLLOW");
let msg = format!("{err}");
assert!(
msg.contains(symlink_path.to_str().unwrap()),
"rejection must include the symlinked path for operator triage; got {msg}"
);
let ok = TrustAnchors::load_from_path(&real_path).expect("real path loadable");
assert_eq!(ok.bytes, b"REAL-KEY-BYTES");
}
}