use anyhow::{Context, Result, bail}; use chrono::Utc; use clap::{Parser, Subcommand, ValueEnum}; use kez_channels::nostr as nostr_chan; use kez_channels::{ChannelHit, Registry, parse_proof}; use kez_core::{ ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer, Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24, seed_from_mnemonic, }; use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Parser)] #[command(name = "kez")] #[command(about = "KEZ portable identity graph CLI")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Debug, Subcommand)] enum Command { Identity { #[command(subcommand)] command: IdentityCommand, }, Claim { #[command(subcommand)] command: ClaimCommand, }, Verify { #[command(subcommand)] command: VerifyCommand, }, Sigchain { #[command(subcommand)] command: SigchainCommand, }, } #[derive(Debug, Subcommand)] enum SigchainCommand { /// Append an `add` event to the chain for the signing key. Add { subject: String, #[arg(long, conflicts_with = "ed25519_seed")] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] mnemonic: Option, #[arg(long)] proof_url: Option, }, /// Append a `revoke` event to the chain for the signing key. Revoke { subject: String, #[arg(long, conflicts_with = "ed25519_seed")] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] mnemonic: Option, }, /// Print the chain (events one per line, plus a summary). Show { /// Read-only: identify the chain by primary alone, no key needed. #[arg(long)] primary: Option, #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] mnemonic: Option, }, /// Export the chain in a portable format. Export { #[arg(long)] primary: Option, #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] mnemonic: Option, #[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)] format: ExportFormat, #[arg(long)] out: Option, }, /// Publish the chain to one or more destinations. Publish { #[arg(long)] primary: Option, #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] mnemonic: Option, /// POST every event to a kez-sig-server at this URL. #[arg(long)] server: Option, /// Write the chain as JSONL to a file (you upload it yourself). #[arg(long)] web: bool, /// Output path for `--web` (required if `--web` is set). #[arg(long, requires = "web")] out: Option, /// Print a DNS TXT zone record for `_kez-chain.` with the /// compact bundle. You install it in your registrar. #[arg(long)] dns: Option, /// Publish the compact bundle as a kind-30078 event to this nostr /// relay. Requires `--nsec` (not `--ed25519-seed`). #[arg(long)] nostr: Option, }, } #[derive(Debug, Clone, Copy, ValueEnum)] enum ExportFormat { Jsonl, Compact, } #[derive(Debug, Subcommand)] enum IdentityCommand { /// Generate a new primary key. Defaults to nostr; pass --key-type /// ed25519 for an Ed25519 key. For Ed25519, a 24-word BIP-39 phrase /// is also printed (it's an equivalent representation of the seed). /// Use --mnemonic-words 12 to generate from a 12-word phrase instead. New { #[arg(long, value_enum, default_value_t = KeyType::Nostr)] key_type: KeyType, /// 12 or 24. Only valid with --key-type ed25519. If unset, a /// 24-word phrase is shown alongside the hex seed for Ed25519. #[arg(long = "mnemonic-words")] mnemonic_words: Option, }, /// Print a fresh BIP-39 mnemonic phrase without deriving a key. /// Useful for offline backup workflows. Mnemonic { /// 12 or 24. Default 24. #[arg(long, default_value_t = 24)] words: u8, }, /// Derive and print the Ed25519 primary key from an existing /// BIP-39 phrase (12 or 24 words, auto-detected). FromMnemonic { /// The phrase, quoted. Words separated by spaces. Case- and /// whitespace-tolerant. phrase: String, }, } #[derive(Debug, Clone, Copy, ValueEnum)] enum KeyType { Nostr, Ed25519, } #[derive(Debug, Subcommand)] enum ClaimCommand { Create { subject: String, #[arg(long, conflicts_with = "ed25519_seed")] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] mnemonic: Option, #[arg(long, value_enum, default_value_t = OutputFormat::Json)] format: OutputFormat, #[arg(long)] out: Option, }, Dns { domain: String, #[arg(long, conflicts_with = "ed25519_seed")] nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] mnemonic: Option, }, } #[derive(Debug, Subcommand)] enum VerifyCommand { /// Verify a local proof file (developer helper). File { path: PathBuf }, /// Verify any KEZ identifier (dns:, github:, ...) by dispatching to its channel. Id { identifier: String }, } #[derive(Debug, Clone, Copy, ValueEnum)] enum OutputFormat { Json, Markdown, Compact, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Command::Identity { command } => match command { IdentityCommand::New { key_type, mnemonic_words } => { identity_new(key_type, mnemonic_words) } IdentityCommand::Mnemonic { words } => identity_mnemonic(words), IdentityCommand::FromMnemonic { phrase } => identity_from_mnemonic(&phrase), }, Command::Claim { command } => match command { ClaimCommand::Create { subject, nsec, ed25519_seed, mnemonic, format, out, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; claim_create(subject, nsec, ed25519_seed, format, out) } ClaimCommand::Dns { domain, nsec, ed25519_seed, mnemonic, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; claim_dns(domain, nsec, ed25519_seed) } }, Command::Verify { command } => match command { VerifyCommand::File { path } => verify_file(path), VerifyCommand::Id { identifier } => verify_identifier(identifier).await, }, Command::Sigchain { command } => sigchain_dispatch(command).await, } } /// If the caller passed `--mnemonic `, derive the Ed25519 seed /// from it and return as hex. Otherwise return the `--ed25519-seed` /// passthrough unchanged. Clap conflicts_with ensures both can't be /// set at once. fn resolve_seed( ed25519_seed: Option, mnemonic: Option, ) -> Result> { match (ed25519_seed, mnemonic) { (Some(s), None) => Ok(Some(s)), (None, Some(phrase)) => { let seed = seed_from_mnemonic(&phrase) .map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?; Ok(Some(hex::encode(seed))) } (None, None) => Ok(None), (Some(_), Some(_)) => unreachable!("clap conflicts_with"), } } async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> { match cmd { SigchainCommand::Add { subject, nsec, ed25519_seed, mnemonic, proof_url, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; sigchain_add(subject, nsec, ed25519_seed, proof_url) } SigchainCommand::Revoke { subject, nsec, ed25519_seed, mnemonic, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; sigchain_revoke(subject, nsec, ed25519_seed) } SigchainCommand::Show { primary, nsec, ed25519_seed, mnemonic, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; sigchain_show(primary, nsec, ed25519_seed) } SigchainCommand::Export { primary, nsec, ed25519_seed, mnemonic, format, out, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; sigchain_export(primary, nsec, ed25519_seed, format, out) } SigchainCommand::Publish { primary, nsec, ed25519_seed, mnemonic, server, web, out, dns, nostr, } => { let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?; sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await } } } fn identity_new(key_type: KeyType, mnemonic_words: Option) -> Result<()> { match (key_type, mnemonic_words) { (KeyType::Nostr, Some(_)) => { bail!("--mnemonic-words is only valid with --key-type ed25519"); } (KeyType::Nostr, None) => { let secret = NostrSecret::generate(); println!("Primary: nostr:{}", secret.npub()); println!("Public: {}", secret.npub()); println!("Secret: {}", secret.nsec()); println!(); println!( "Store the secret somewhere safe. Anyone with the nsec can sign as this identity." ); } (KeyType::Ed25519, words_opt) => { // Default is 24 — the canonical bijective form (entropy IS seed). let words = MnemonicWords::from_count(words_opt.unwrap_or(24) as usize) .map_err(|e| anyhow::anyhow!("{e}"))?; let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(words) .map_err(|e| anyhow::anyhow!("{e}"))?; let id = secret.identity()?; println!("Primary: {id}"); println!("Public: {}", secret.pubkey_hex()); println!("Secret: {} (32-byte seed)", secret.seed_hex()); println!("Mnemonic ({} words): \"{}\"", words.count(), phrase); println!(); match words { MnemonicWords::TwentyFour => println!( "The 24-word phrase and the hex seed are equivalent backups —\n\ either restores this identity. Store at least one safely." ), MnemonicWords::Twelve => println!( "The 12-word phrase is the canonical backup. The hex seed is\n\ derived from it (one-way) — you can't reconstruct the phrase\n\ from the seed. Store the phrase safely." ), } } } Ok(()) } fn identity_mnemonic(words: u8) -> Result<()> { let w = MnemonicWords::from_count(words as usize).map_err(|e| anyhow::anyhow!("{e}"))?; let phrase = generate_mnemonic(w).map_err(|e| anyhow::anyhow!("{e}"))?; println!("{phrase}"); Ok(()) } fn identity_from_mnemonic(phrase: &str) -> Result<()> { let secret = Ed25519Secret::from_mnemonic(phrase).map_err(|e| anyhow::anyhow!("{e}"))?; let id = secret.identity()?; let word_count = phrase.split_whitespace().count(); println!("Primary: {id}"); println!("Public: {}", secret.pubkey_hex()); println!("Secret: {} (32-byte seed)", secret.seed_hex()); println!("Mnemonic ({} words): \"{}\"", word_count, phrase.trim()); if word_count == 24 { // For 24-word, verify it round-trips so the user knows it's canonical. let mut seed_bytes = [0u8; 32]; seed_bytes.copy_from_slice(&hex::decode(secret.seed_hex())?); let derived = mnemonic_from_seed_24(&seed_bytes).map_err(|e| anyhow::anyhow!("{e}"))?; if derived.trim() != phrase.trim() { // Words were correct (parse succeeded) but their reordering differs // — shouldn't happen, but worth flagging if it ever does. println!("(note: canonical form is \"{}\")", derived); } } Ok(()) } /// Build a signed claim from whichever signing key the caller supplied. /// Exactly one of `nsec` / `ed25519_seed` must be present (clap enforces). fn build_claim( subject: String, nsec: Option, ed25519_seed: Option, ) -> Result { let subject = Identity::parse(subject)?; match (nsec, ed25519_seed) { (Some(nsec), None) => { let signer = NostrSecret::from_nsec(&nsec).context("invalid nsec")?; let primary = Identity::parse(format!("nostr:{}", signer.npub()))?; let payload = ClaimPayload::new(subject, primary, Utc::now()); Ok(SignedClaim::sign_with(payload, Signer::Nostr(&signer))?) } (None, Some(seed)) => { let signer = Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?; let primary = signer.identity()?; let payload = ClaimPayload::new(subject, primary, Utc::now()); Ok(SignedClaim::sign_with(payload, Signer::Ed25519(&signer))?) } (None, None) => anyhow::bail!("missing key: pass --nsec or --ed25519-seed"), (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"), } } fn claim_create( subject: String, nsec: Option, ed25519_seed: Option, format: OutputFormat, out: Option, ) -> Result<()> { let signed = build_claim(subject, nsec, ed25519_seed)?; let output = match format { OutputFormat::Json => signed.to_pretty_json()?, OutputFormat::Markdown => signed.to_markdown_proof()?, OutputFormat::Compact => signed.to_compact()?, }; write_or_print(out, &output) } fn claim_dns( domain: String, nsec: Option, ed25519_seed: Option, ) -> Result<()> { let subject = if domain.starts_with("dns:") { domain } else { format!("dns:{domain}") }; let signed = build_claim(subject, nsec, ed25519_seed)?; let name = dns_txt_name(&signed.payload.subject)?; let value = signed.to_compact()?; println!("Name: {name}"); println!("Value: {value}"); println!(); println!("Zone file:"); println!("{name} TXT {}", quote_dns_txt_value(&value)); Ok(()) } fn verify_file(path: PathBuf) -> Result<()> { let raw = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let proof = parse_proof(&raw).context("failed to parse KEZ proof")?; let status = proof.verify().context("signature verification failed")?; print_status(&status); Ok(()) } async fn verify_identifier(identifier: String) -> Result<()> { let identity = Identity::parse(identifier).context("invalid KEZ identifier")?; let registry = Registry::with_defaults() .map_err(|e| anyhow::anyhow!("{e}")) .context("failed to build channel registry")?; let hit: ChannelHit = registry .verify(&identity) .await .map_err(|e| anyhow::anyhow!("{e}"))?; print_status(&hit.status); Ok(()) } fn print_status(status: &VerificationStatus) { println!("Primary: {}", status.primary); println!(); println!("Verified identities:"); for identity in &status.verified { println!("- {identity}"); } println!(); println!("Status: {}", status.status); println!("Confidence: {}", status.confidence); } fn write_or_print(out: Option, output: &str) -> Result<()> { match out { Some(path) => { fs::write(&path, output).with_context(|| format!("failed to write {}", path.display())) } None => { // Match Node's `writeOrPrint`: avoid double-newlines if `output` // already ends in one (the sigchain JSONL case). if output.ends_with('\n') { print!("{output}"); } else { println!("{output}"); } Ok(()) } } } // ───────────────────────────────────────────────────────────────────────────── // Sigchain commands // ───────────────────────────────────────────────────────────────────────────── /// Where the local chain for `primary` lives on disk. fn sigchain_path(primary: &Identity) -> Result { let home = dirs::home_dir().context("could not determine home directory")?; let dir = home.join(".kez").join("sigchains"); fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; let safe = primary.as_str().replace(':', "_"); Ok(dir.join(format!("{safe}.jsonl"))) } /// Load the chain for `primary` from disk, or return an empty chain if no /// file exists. fn load_chain(primary: &Identity) -> Result { let path = sigchain_path(primary)?; if !path.exists() { return Ok(Sigchain::new(primary.clone())); } let text = fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; Ok(Sigchain::from_jsonl(&text)?) } fn save_chain(chain: &Sigchain) -> Result<()> { let path = sigchain_path(chain.primary())?; let text = chain.to_jsonl()?; fs::write(&path, text).with_context(|| format!("write {}", path.display()))?; Ok(()) } /// Build a `Signer` borrow from whichever flag the user passed. /// Returns the loaded keys so the caller can keep them alive. enum SignerKeys { Nostr(NostrSecret), Ed25519(Ed25519Secret), } impl SignerKeys { fn from_flags(nsec: Option, ed25519_seed: Option) -> Result { match (nsec, ed25519_seed) { (Some(nsec), None) => Ok(Self::Nostr( NostrSecret::from_nsec(&nsec).context("invalid nsec")?, )), (None, Some(seed)) => Ok(Self::Ed25519( Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?, )), (None, None) => bail!("missing key: pass --nsec or --ed25519-seed"), (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"), } } fn primary(&self) -> Result { match self { SignerKeys::Nostr(s) => Identity::parse(format!("nostr:{}", s.npub())), SignerKeys::Ed25519(s) => s.identity(), } .map_err(Into::into) } fn as_signer(&self) -> Signer<'_> { match self { SignerKeys::Nostr(s) => Signer::Nostr(s), SignerKeys::Ed25519(s) => Signer::Ed25519(s), } } } /// Resolve the primary identity for a read-only command. Accepts `--primary` /// directly, or derives it from a signing key. fn resolve_primary_readonly( primary: Option, nsec: Option, ed25519_seed: Option, ) -> Result { if let Some(p) = primary { return Ok(Identity::parse(p)?); } SignerKeys::from_flags(nsec, ed25519_seed)?.primary() } fn sigchain_add( subject: String, nsec: Option, ed25519_seed: Option, proof_url: Option, ) -> Result<()> { let keys = SignerKeys::from_flags(nsec, ed25519_seed)?; let primary = keys.primary()?; let subject = Identity::parse(subject)?; let mut chain = load_chain(&primary)?; let event = chain.sign_add(subject.clone(), proof_url, keys.as_signer())?; println!( "Appended add {} at seq {} (head hash: {})", subject, event.payload.seq, event.hash()? ); save_chain(&chain)?; println!("Chain saved to {}", sigchain_path(&primary)?.display()); Ok(()) } fn sigchain_revoke( subject: String, nsec: Option, ed25519_seed: Option, ) -> Result<()> { let keys = SignerKeys::from_flags(nsec, ed25519_seed)?; let primary = keys.primary()?; let subject = Identity::parse(subject)?; let mut chain = load_chain(&primary)?; let event = chain.sign_revoke(subject.clone(), keys.as_signer())?; println!( "Appended revoke {} at seq {} (head hash: {})", subject, event.payload.seq, event.hash()? ); save_chain(&chain)?; println!("Chain saved to {}", sigchain_path(&primary)?.display()); Ok(()) } fn sigchain_show( primary: Option, nsec: Option, ed25519_seed: Option, ) -> Result<()> { let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?; let chain = load_chain(&primary)?; println!("Primary: {primary}"); println!("Path: {}", sigchain_path(&primary)?.display()); println!("Length: {} event(s)", chain.len()); println!(); for (i, event) in chain.events().iter().enumerate() { let subject = event .payload .subject() .map(|s| s.to_string()) .unwrap_or_else(|| "".into()); println!( " [{i}] seq={} op={:6} subject={subject}", event.payload.seq, event.payload.op ); } if !chain.is_empty() { println!(); println!("Head hash: {}", chain.head_hash()?.unwrap()); } Ok(()) } fn sigchain_export( primary: Option, nsec: Option, ed25519_seed: Option, format: ExportFormat, out: Option, ) -> Result<()> { let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?; let chain = load_chain(&primary)?; if chain.is_empty() { bail!("no chain found for {primary}"); } let output = match format { ExportFormat::Jsonl => chain.to_jsonl()?, ExportFormat::Compact => chain.to_compact_bundle()?, }; write_or_print(out, &output) } async fn sigchain_publish( primary: Option, nsec: Option, ed25519_seed: Option, server: Option, web: bool, out: Option, dns: Option, nostr: Option, ) -> Result<()> { // Need at least one destination. if server.is_none() && !web && dns.is_none() && nostr.is_none() { bail!("no publish destination: pass --server / --web / --dns / --nostr"); } // Resolve the primary and (optionally) the signer. // For --nostr we need a NostrSecret specifically (to sign the wrapping // event); for other destinations we just need the chain. let (primary, nsec_signer): (Identity, Option) = if let Some(p) = primary { (Identity::parse(p)?, None) } else { let keys = SignerKeys::from_flags(nsec, ed25519_seed)?; let primary = keys.primary()?; let nsec_signer = match keys { SignerKeys::Nostr(s) => Some(s), SignerKeys::Ed25519(_) => None, }; (primary, nsec_signer) }; let chain = load_chain(&primary)?; if chain.is_empty() { bail!("no chain found for {primary}"); } if let Some(server_url) = server.as_deref() { publish_to_server(&chain, server_url).await?; } if web { let out = out.context("--web requires --out ")?; publish_to_web(&chain, &out)?; } if let Some(domain) = dns.as_deref() { publish_to_dns(&chain, domain)?; } if let Some(relay) = nostr.as_deref() { let signer = nsec_signer .as_ref() .context("--nostr publish requires --nsec (nostr key needed to sign the wrapping event)")?; // The wrapping nostr key must match the chain's primary, otherwise // verifiers can't tie the event to the identity. let signer_primary = Identity::parse(format!("nostr:{}", signer.npub()))?; if signer_primary != primary { bail!( "--nostr publish requires the signing nsec to match the chain primary ({primary}); got {signer_primary}" ); } publish_to_nostr(&chain, relay, signer).await?; } Ok(()) } async fn publish_to_server(chain: &Sigchain, server_url: &str) -> Result<()> { let base = server_url.trim_end_matches('/'); let scheme = chain.primary().scheme(); let id = chain.primary().value(); let client = reqwest::Client::builder() .user_agent("kez-cli/0.1") .build()?; let endpoint = format!("{base}/v1/sigchains/{scheme}/{id}/events"); let mut posted = 0; let mut already_present = 0; for event in chain.events() { let resp = client .post(&endpoint) .json(event) .send() .await .with_context(|| format!("POST {endpoint}"))?; let status = resp.status(); if status.is_success() { posted += 1; } else if status == reqwest::StatusCode::CONFLICT { // Idempotent: server already has this seq, fine. already_present += 1; } else { let body = resp.text().await.unwrap_or_default(); bail!("POST {endpoint}: {status} {body}"); } } println!( "server({server_url}): posted {posted} event(s), {already_present} already present" ); Ok(()) } fn publish_to_web(chain: &Sigchain, path: &Path) -> Result<()> { let text = chain.to_jsonl()?; fs::write(path, text).with_context(|| format!("write {}", path.display()))?; println!( "web: wrote {} event(s) to {} (upload to https:///.well-known/kez-sigchain.jsonl)", chain.len(), path.display() ); Ok(()) } fn publish_to_dns(chain: &Sigchain, domain: &str) -> Result<()> { let compact = chain.to_compact_bundle()?; let name = format!("_kez-chain.{domain}"); println!("dns({domain}):"); println!(" Name: {name}"); println!(" Value: {compact}"); println!(); println!(" Zone file (install in your DNS registrar):"); println!(" {name} TXT {}", quote_dns_txt_value(&compact)); Ok(()) } async fn publish_to_nostr( chain: &Sigchain, relay: &str, signer: &NostrSecret, ) -> Result<()> { let content = chain.to_compact_bundle()?; let event = nostr_chan::build_signed_event( signer, Utc::now().timestamp(), nostr_chan::KEZ_NOSTR_KIND, vec![vec!["d".into(), "kez-sigchain".into()]], content, ) .map_err(|e| anyhow::anyhow!("{e}"))?; nostr_chan::publish_event_to_relay(relay, &event) .await .map_err(|e| anyhow::anyhow!("{e}"))?; println!( "nostr({relay}): published kind-{} event {}", nostr_chan::KEZ_NOSTR_KIND, event.id ); Ok(()) } fn quote_dns_txt_value(value: &str) -> String { value .chars() .collect::>() .chunks(240) .map(|chunk| { let escaped = chunk .iter() .flat_map(|ch| match ch { '"' => ['\\', '"'].into_iter().collect::>(), '\\' => ['\\', '\\'].into_iter().collect::>(), other => [*other].into_iter().collect::>(), }) .collect::(); format!("\"{escaped}\"") }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn quote_dns_txt_value_chunks_long_inputs() { let value = "a".repeat(500); let quoted = quote_dns_txt_value(&value); // 500 chars / 240 chunk -> 3 quoted segments. let segments = quoted.split(' ').count(); assert_eq!(segments, 3); assert!(quoted.starts_with('"')); assert!(quoted.ends_with('"')); } #[test] fn quote_dns_txt_value_escapes_quotes_and_backslashes() { let quoted = quote_dns_txt_value(r#"hello "kez" \n"#); assert!(quoted.contains(r#"\""#)); assert!(quoted.contains(r"\\")); } }