use std::io;
use clap::{Args, Parser};
use clap_cargo::style::CLAP_STYLING;
use clap_verbosity_flag::{InfoLevel, Verbosity};
use color_eyre::Result;
use color_eyre::eyre::{Context, bail};
use colored_json::ToColoredJson;
use tracing::{debug, warn};
use webfinger_rs::{Rel, Resource, WebFingerRequest};
#[derive(Debug, Parser)]
#[clap(styles = CLAP_STYLING)]
struct Cli {
#[command(flatten)]
fetch_command: FetchCommand,
#[command(flatten)]
verbosity: Verbosity<InfoLevel>,
}
#[derive(Debug, Args)]
#[command(next_line_help = false)]
struct FetchCommand {
resource: String,
host: Option<String>,
#[arg(short, long)]
rel: Vec<String>,
#[arg(long)]
insecure: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let args = Cli::parse();
tracing_subscriber::fmt()
.with_max_level(args.verbosity)
.with_writer(io::stderr)
.init();
args.fetch_command.execute().await?;
Ok(())
}
impl FetchCommand {
async fn execute(&self) -> Result<()> {
let resource = self.resource()?;
let request = WebFingerRequest {
host: self.host(&resource)?,
resource,
rels: self.link_relations()?,
};
debug!("fetching webfinger resource: {:?}", request);
if self.insecure {
warn!("ignoring TLS certificate verification errors");
}
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(self.insecure)
.build()?;
let response = request.execute_reqwest_with_client(&client).await?;
let json = response.to_string().to_colored_json_auto()?;
println!("{json}");
Ok(())
}
fn host(&self, resource: &Resource) -> Result<String> {
if let Some(host) = self.host.as_deref() {
Ok(host.to_string())
} else {
if let Some(host) = resource.host() {
debug!("extracted host from resource URI: {}", host);
Ok(host.to_string())
} else if let Some((_, host)) = self.resource.split_once('@') {
debug!("extracted host from acct resource: {}", host);
Ok(host.to_string())
} else {
bail!("no host provided")
}
}
}
fn resource(&self) -> Result<Resource> {
self.resource.parse().wrap_err("invalid resource")
}
fn link_relations(&self) -> Result<Vec<Rel>> {
self.rel
.iter()
.map(Rel::try_new)
.collect::<std::result::Result<Vec<_>, _>>()
.wrap_err("invalid relation type")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(resource: &str) -> FetchCommand {
FetchCommand {
resource: resource.to_string(),
host: None,
rel: Vec::new(),
insecure: false,
}
}
#[test]
fn host_uses_resource_uri_authority() {
let command = command("https://example.org/users/@carol");
let resource = command.resource().unwrap();
let host = command.host(&resource).unwrap();
assert_eq!(host, "example.org");
}
#[test]
fn host_falls_back_to_acct_authority() {
let command = command("acct:carol@example.org");
let resource = command.resource().unwrap();
let host = command.host(&resource).unwrap();
assert_eq!(host, "example.org");
}
#[test]
fn resource_rejects_http_uri_without_authority() {
let command = command("http:foo");
let error = command
.resource()
.expect_err("HTTP resource without authority");
assert!(error.to_string().contains("invalid resource"));
}
}