use std::net::{IpAddr, ToSocketAddrs as _};
use anyhow::{Context as _, anyhow, bail};
#[derive(Debug, Clone)]
pub(crate) struct Target {
pub host: IpAddr,
pub source: Source,
}
#[derive(Debug, Clone)]
pub(crate) enum Source {
Export(String),
Handle(String),
None,
}
pub(crate) fn parse(positional: &str, export_flag: Option<&str>, handle_flag: Option<&str>, require_source: bool) -> anyhow::Result<Target> {
let (host_part, colon_export) = split_host_export(positional)?;
if colon_export.is_some() && export_flag.is_some() {
bail!("ambiguous target: both '<HOST>:/path' and --export given for {positional:?}");
}
if colon_export.is_some() && handle_flag.is_some() {
bail!("ambiguous target: both '<HOST>:/path' and --handle given for {positional:?}");
}
if export_flag.is_some() && handle_flag.is_some() {
bail!("--export and --handle are mutually exclusive");
}
let source = match (colon_export, export_flag, handle_flag) {
(Some(p), _, _) | (_, Some(p), _) => Source::Export(p.to_owned()),
(_, _, Some(h)) => Source::Handle(h.to_owned()),
(None, None, None) => {
if require_source {
bail!("missing export: pass <HOST>:/path, --export PATH, or --handle HEX");
}
Source::None
},
};
let host = resolve_host(host_part)?;
Ok(Target { host, source })
}
fn split_host_export(s: &str) -> anyhow::Result<(&str, Option<&str>)> {
if s.is_empty() {
bail!("empty target");
}
let scan_from = if let Some(stripped) = s.strip_prefix('[') {
let close = stripped.find(']').ok_or_else(|| anyhow!("unterminated '[' in target {s:?}"))?;
close + 2
} else {
0
};
if let Some(rel) = s[scan_from..].find(":/") {
let split = scan_from + rel;
let host = &s[..split];
let export = &s[split + 1..]; if export.is_empty() {
bail!("empty export path after ':' in target {s:?}");
}
Ok((host, Some(export)))
} else {
Ok((s, None))
}
}
pub(crate) fn resolve_host(host: &str) -> anyhow::Result<IpAddr> {
let bare = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = bare.parse::<IpAddr>() {
return Ok(ip);
}
let mut addrs = format!("{bare}:0").to_socket_addrs().with_context(|| format!("cannot resolve '{host}' to an IP address"))?;
addrs.next().map(|sa| sa.ip()).ok_or_else(|| anyhow!("no addresses found for host '{host}'"))
}
impl Target {
#[must_use]
pub(crate) const fn export(&self) -> Option<&str> {
match &self.source {
Source::Export(p) => Some(p.as_str()),
_ => None,
}
}
#[cfg(test)]
#[must_use]
pub(crate) const fn handle_hex(&self) -> Option<&str> {
match &self.source {
Source::Handle(h) => Some(h.as_str()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipv4_with_colon_export() {
let t = parse("10.0.0.5:/srv", None, None, true).unwrap();
assert_eq!(t.host.to_string(), "10.0.0.5");
assert_eq!(t.export(), Some("/srv"));
}
#[test]
fn ipv4_bare_with_export_flag() {
let t = parse("10.0.0.5", Some("/srv"), None, true).unwrap();
assert_eq!(t.export(), Some("/srv"));
}
#[test]
fn ipv4_bare_with_handle_flag() {
let t = parse("10.0.0.5", None, Some("DEADBEEF"), true).unwrap();
assert_eq!(t.handle_hex(), Some("DEADBEEF"));
}
#[test]
fn ipv6_bracketed_colon_form() {
let t = parse("[2001:db8::1]:/srv", None, None, true).unwrap();
assert_eq!(t.host.to_string(), "2001:db8::1");
assert_eq!(t.export(), Some("/srv"));
}
#[test]
fn ipv6_bracketed_no_export() {
let t = parse("[2001:db8::1]", Some("/srv"), None, true).unwrap();
assert_eq!(t.host.to_string(), "2001:db8::1");
assert_eq!(t.export(), Some("/srv"));
}
#[test]
fn colon_form_clashes_with_export_flag() {
let err = parse("10.0.0.5:/srv", Some("/other"), None, true).unwrap_err();
assert!(err.to_string().contains("ambiguous"));
}
#[test]
fn colon_form_clashes_with_handle_flag() {
let err = parse("10.0.0.5:/srv", None, Some("BEEF"), true).unwrap_err();
assert!(err.to_string().contains("ambiguous"));
}
#[test]
fn export_and_handle_clash() {
let err = parse("10.0.0.5", Some("/srv"), Some("BEEF"), true).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn bare_host_without_source_when_required() {
let err = parse("10.0.0.5", None, None, true).unwrap_err();
assert!(err.to_string().contains("missing export"));
}
#[test]
fn bare_host_without_source_when_not_required() {
let t = parse("10.0.0.5", None, None, false).unwrap();
assert!(matches!(t.source, Source::None));
}
#[test]
fn colon_with_empty_export_rejected() {
let err = parse("10.0.0.5:", None, None, true).unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn root_export_via_colon() {
let t = parse("10.0.0.5:/", None, None, true).unwrap();
assert_eq!(t.export(), Some("/"));
}
#[test]
fn unterminated_ipv6_bracket_rejected() {
let err = parse("[2001:db8::1", None, None, true).unwrap_err();
assert!(err.to_string().contains("unterminated"));
}
#[test]
fn resolve_host_dns_name() {
let ip = resolve_host("localhost").unwrap();
assert!(ip.is_loopback(), "localhost should resolve to a loopback address, got {ip}");
}
}