From e8dc69dd169d1cbd6012e5572fb246596776b292 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 16 Oct 2026 13:09:57 -0700 Subject: [PATCH 1/3] feat(domain): accept and show internationalized domain names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain names, DNS record names and hosting hostnames can be typed as Unicode (münchen.de) or in encoded form (xn--mnchen-3ya.de); both reach the same domain. - Names are shown as Unicode by default. Add --punycode to show the encoded form instead. Available on domain list, get, available, suggest, quote, purchase, nameserver set and operation status; dns list, add, set and delete; and hosting domain attach, get and list. - Domain names are lowercased before they are sent. - DNS record values (CNAME, MX, TXT and so on) are never converted. Co-Authored-By: Claude Sonnet 5.5 --- rust/Cargo.lock | 1 + rust/Cargo.toml | 1 + rust/src/dns/add.rs | 13 +- rust/src/dns/delete.rs | 31 ++-- rust/src/dns/guides/dns.md | 4 + rust/src/dns/list.rs | 15 +- rust/src/dns/records.rs | 86 ++++++++++- rust/src/dns/set/mod.rs | 21 +-- rust/src/domain/available.rs | 8 ++ rust/src/domain/common.rs | 27 ++-- rust/src/domain/get.rs | 7 +- rust/src/domain/list.rs | 9 +- rust/src/domain/nameservers.rs | 12 +- rust/src/domain/operation.rs | 6 + rust/src/domain/purchase.rs | 5 + rust/src/domain/quote.rs | 12 +- rust/src/domain/suggest.rs | 16 ++- rust/src/hosting/domain/attach.rs | 14 +- rust/src/hosting/domain/get.rs | 7 +- rust/src/hosting/domain/list.rs | 7 + rust/src/idn.rs | 228 ++++++++++++++++++++++++++++++ rust/src/main.rs | 1 + 22 files changed, 460 insertions(+), 71 deletions(-) create mode 100644 rust/src/idn.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f5d5a855..abeda6ee 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1504,6 +1504,7 @@ dependencies = [ "globset", "hosting-client", "httpmock", + "idna", "iso_currency", "jsonschema", "open", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ada95fcf..0ef5d734 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,6 +39,7 @@ fancy-regex = "0.14" flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] } futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] } globset = "0.4" +idna = "1" open = "5.4.1" oxc_allocator = "0.143" oxc_ast = "0.143" diff --git a/rust/src/dns/add.rs b/rust/src/dns/add.rs index de111fdd..13d63ec0 100644 --- a/rust/src/dns/add.rs +++ b/rust/src/dns/add.rs @@ -1,6 +1,6 @@ //! `dns add` — append new DNS records to a domain without touching existing ones. -use cli_engine::{Alignment, CommandResult, CommandSpec, RuntimeCommandSpec, TableColumn, Tier}; +use cli_engine::{Alignment, CommandSpec, RuntimeCommandSpec, TableColumn, Tier}; use serde_json::{Value, json}; use crate::domain::make_client; @@ -10,7 +10,7 @@ use crate::scopes::DOMAINS_DNS_UPDATE; use super::conflicts::{WriteErrorContext, describe_write_error}; use super::records::{ RecordOptions, RecordWriteArgs, v3_records, validate_caa_fields, validate_svcb_fields, - validate_tlsa_fields, verify_with_list_action, + validate_tlsa_fields, verified_result, }; // `dns add` creates one v3 record per `--data` value; it reports each outcome @@ -124,6 +124,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let domain = args.domain; let record_type = args.record_type; let name = args.name; + let domain_format = args.domain_format.form(); validate_caa_fields(&record_type, &opts) .map_err(crate::error::GddyError::validation)?; validate_tlsa_fields(&record_type, &opts) @@ -171,13 +172,7 @@ pub(super) fn command() -> RuntimeCommandSpec { // All-created → success payload; any failure → non-zero error // with a per-record breakdown. summarize_add_outcomes(&domain, &record_type, &name, outcomes) - .map(|v| { - CommandResult::new(v).with_next_actions(vec![verify_with_list_action( - &domain, - &record_type, - &name, - )]) - }) + .map(|v| verified_result(domain_format, v, &domain, &record_type, &name)) .map_err(super::mutate_failed) }, ) diff --git a/rust/src/dns/delete.rs b/rust/src/dns/delete.rs index 4d4e5ba7..da1383cf 100644 --- a/rust/src/dns/delete.rs +++ b/rust/src/dns/delete.rs @@ -4,12 +4,15 @@ use cli_engine::{Alignment, CommandResult, CommandSpec, RuntimeCommandSpec, Tabl use serde_json::{Value, json}; use crate::domain::{api_error, make_client}; +use crate::idn::DomainFormArgs; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_DNS_UPDATE; use domains_client::types; -use super::records::{fetch_records, parse_write_type_arg, record_value, verify_with_list_action}; +use super::records::{ + fetch_records, parse_dns_name_arg, parse_write_type_arg, record_value, verified_result, +}; output_schema!(DnsDeleteResult { "domain": "string"; @@ -114,7 +117,7 @@ fn dry_run_delete_preview( #[derive(Debug, Clone, clap::Args)] struct DeleteArgs { /// Domain whose records to delete (e.g. example.com). - #[arg(value_name = "DOMAIN")] + #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] domain: String, /// Record type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, SVCB, TLSA, TXT). @@ -122,8 +125,11 @@ struct DeleteArgs { record_type: String, /// Record name relative to the domain (e.g. www). - #[arg(long, value_name = "NAME")] + #[arg(long, value_name = "NAME", value_parser = parse_dns_name_arg)] name: String, + + #[command(flatten)] + domain_format: DomainFormArgs, } /// `records` only appears on the `--dry-run` preview (the real delete's @@ -170,6 +176,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let domain = args.domain; let record_type = args.record_type; let name = args.name; + let domain_format = args.domain_format.form(); let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; @@ -185,13 +192,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .await?; if ctx.dry_run() { - return Ok(CommandResult::new(dry_run_delete_preview( - &domain, - &record_type, - &name, - &existing, - )) - .with_dry_run()); + let mut preview = dry_run_delete_preview(&domain, &record_type, &name, &existing); + domain_format.convert_dns_fields(&mut preview); + return Ok(CommandResult::new(preview).with_dry_run()); } let mut outcomes = Vec::with_capacity(existing.len()); @@ -221,13 +224,7 @@ pub(super) fn command() -> RuntimeCommandSpec { } summarize_delete_outcomes(&domain, &record_type, &name, &outcomes) - .map(|v| { - CommandResult::new(v).with_next_actions(vec![verify_with_list_action( - &domain, - &record_type, - &name, - )]) - }) + .map(|v| verified_result(domain_format, v, &domain, &record_type, &name)) .map_err(super::mutate_failed) }, ) diff --git a/rust/src/dns/guides/dns.md b/rust/src/dns/guides/dns.md index a786066c..9ef96c19 100644 --- a/rust/src/dns/guides/dns.md +++ b/rust/src/dns/guides/dns.md @@ -65,6 +65,10 @@ gddy dns delete example.com --type A --name www Deleting something that doesn't match is a no-op success (`deleted: 0`), not an error. Preview with `--dry-run` first for anything you're not fully sure about. +## International domain names + +Domain names and record names can be typed either as Unicode (`münchen.de`) or in their encoded form (`xn--mnchen-3ya.de`); both reach the same domain. Output shows Unicode by default. Add `--punycode` to any DNS command to show the encoded form instead. Record values (the `--data` of a CNAME, MX or TXT record) are never converted; enter them exactly as they should be stored. + ## Type-specific flags Most of these only apply to certain record types — set them only when the type calls for it: diff --git a/rust/src/dns/list.rs b/rust/src/dns/list.rs index e8875be6..2fa0c1d2 100644 --- a/rust/src/dns/list.rs +++ b/rust/src/dns/list.rs @@ -7,16 +7,17 @@ use cli_engine::{ use serde_json::{Value, json}; use crate::domain::make_client; +use crate::idn::DomainFormArgs; use crate::scopes::DOMAINS_READ; use domains_client::types; -use super::records::{fetch_records, merged_tlsa_data, parse_list_type_arg}; +use super::records::{fetch_records, merged_tlsa_data, parse_dns_name_arg, parse_list_type_arg}; #[derive(Debug, Clone, clap::Args)] struct ListArgs { /// Domain whose records to list (e.g. example.com). - #[arg(value_name = "DOMAIN")] + #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] domain: String, /// Only records of this type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, NS, @@ -25,8 +26,11 @@ struct ListArgs { record_type: Option, /// Only records with this name (requires --type). - #[arg(long, value_name = "NAME", requires = "record_type")] + #[arg(long, value_name = "NAME", requires = "record_type", value_parser = parse_dns_name_arg)] name: Option, + + #[command(flatten)] + domain_format: DomainFormArgs, } /// A record is unreadable without its type/name/data, so those three are @@ -85,9 +89,12 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .await?; - let out = build_list_output(&records).map_err(|e| { + let mut out = build_list_output(&records).map_err(|e| { CliCoreError::message(format!("failed to serialize DNS records: {e}")) })?; + for record in &mut out { + args.domain_format.form().convert_dns_fields(record); + } Ok(CommandResult::new(json!(out))) }, ) diff --git a/rust/src/dns/records.rs b/rust/src/dns/records.rs index 03990700..8a2de4e6 100644 --- a/rust/src/dns/records.rs +++ b/rust/src/dns/records.rs @@ -2,9 +2,11 @@ //! subcommand: type/name validation, the `RecordOptions` bundle, converting //! CLI input into v3 `DnsRecord`s, and the paginated `list_dns_records` fetch. -use cli_engine::{CliCoreError, NextAction, NextActionParam}; +use cli_engine::{CliCoreError, CommandResult, NextAction, NextActionParam}; +use serde_json::Value; use crate::domain::api_error; +use crate::idn::DomainForm; use crate::next_action::next_action; use domains_client::types; @@ -32,6 +34,12 @@ const LIST_PAGE_SIZE: std::num::NonZeroU64 = /// against [`WRITABLE_TYPES`] and return the canonical upper-case wire string. /// The read-only NS/SOA get a clear "managed by GoDaddy" reason. Validating in /// clap rejects invalid input at parse time — before `--dry-run`/auth short-circuits. +/// Clap value-parser for a zone or record name: Unicode names are sent to the +/// API in their ASCII (punycode) form; ASCII names pass through unchanged. +pub(super) fn parse_dns_name_arg(raw: &str) -> Result { + crate::idn::to_ascii(raw).map_err(|e| e.to_string()) +} + pub(super) fn parse_write_type_arg(raw: &str) -> Result { let upper = raw.to_ascii_uppercase(); if WRITABLE_TYPES.contains(&upper.as_str()) { @@ -118,7 +126,7 @@ pub(crate) const CONDITIONALLY_REQUIRED_FLAGS: &[(&str, &str)] = &[ #[derive(Debug, Clone, clap::Args)] pub(super) struct RecordWriteArgs { /// Domain whose records to modify (e.g. example.com). - #[arg(value_name = "DOMAIN")] + #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] pub(super) domain: String, // `ignore_case` is load-bearing, not cosmetic. The `required_if_eq` predicates @@ -131,9 +139,12 @@ pub(super) struct RecordWriteArgs { pub(super) record_type: String, /// Record name relative to the domain (e.g. www, @ for the apex). - #[arg(long, value_name = "NAME")] + #[arg(long, value_name = "NAME", value_parser = parse_dns_name_arg)] pub(super) name: String, + #[command(flatten)] + pub(super) domain_format: crate::idn::DomainFormArgs, + /// Record value (repeatable for multiple records on the same name). For /// TLSA, this is the hex-encoded certificate association data; use /// `--usage`/`--selector`/`--matching-type` for the rest. @@ -422,6 +433,23 @@ pub(super) async fn fetch_records( /// Next action pointing back at `dns list` to verify a write, pre-filled with /// the domain/type/name the write just touched. +/// A mutation's success payload plus the "verify with `dns list`" next action, +/// with the zone and record name shown in `domain_format`. +pub(super) fn verified_result( + domain_format: DomainForm, + mut payload: Value, + domain: &str, + record_type: &str, + name: &str, +) -> CommandResult { + domain_format.convert_dns_fields(&mut payload); + CommandResult::new(payload).with_next_actions(vec![verify_with_list_action( + &domain_format.render(domain), + record_type, + &domain_format.render(name), + )]) +} + pub(super) fn verify_with_list_action(domain: &str, record_type: &str, name: &str) -> NextAction { next_action( "dns list --type --name ", @@ -436,6 +464,58 @@ pub(super) fn verify_with_list_action(domain: &str, record_type: &str, name: &st mod tests { use super::*; + #[test] + fn parse_dns_name_arg_encodes_unicode_and_keeps_special_names() { + assert_eq!( + parse_dns_name_arg("münchen.de").expect("valid name"), + "xn--mnchen-3ya.de" + ); + assert_eq!( + parse_dns_name_arg("*.münchen").expect("valid name"), + "*.xn--mnchen-3ya" + ); + for plain in ["example.com", "xn--mnchen-3ya.de", "@", "www", "_dmarc"] { + assert_eq!(parse_dns_name_arg(plain).expect("valid name"), plain); + } + assert!(parse_dns_name_arg("\u{301}abc.com").is_err()); + } + + #[test] + fn verified_result_shows_the_requested_form_of_zone_and_name() { + let payload = || { + serde_json::json!({ + "domain": "xn--mnchen-3ya.de", + "name": "xn--bcher-kva", + "type": "A", + }) + }; + let unicode = verified_result( + DomainForm::Unicode, + payload(), + "xn--mnchen-3ya.de", + "A", + "xn--bcher-kva", + ); + assert_eq!(unicode.data["domain"], "münchen.de"); + assert_eq!(unicode.data["name"], "bücher"); + let params = + serde_json::to_string(&unicode.metadata.next_actions).expect("next actions serialize"); + assert!( + params.contains("münchen.de") && params.contains("bücher"), + "{params}" + ); + + let ascii = verified_result( + DomainForm::Punycode, + payload(), + "xn--mnchen-3ya.de", + "A", + "xn--bcher-kva", + ); + assert_eq!(ascii.data["domain"], "xn--mnchen-3ya.de"); + assert_eq!(ascii.data["name"], "xn--bcher-kva"); + } + fn opts() -> RecordOptions { RecordOptions { ttl: None, diff --git a/rust/src/dns/set/mod.rs b/rust/src/dns/set/mod.rs index 88cd97d2..dd5d4e83 100644 --- a/rust/src/dns/set/mod.rs +++ b/rust/src/dns/set/mod.rs @@ -6,7 +6,7 @@ use crate::domain::{api_error, make_client}; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_DNS_UPDATE; -use super::records::verify_with_list_action; +use super::records::verified_result; use super::records::{ RecordOptions, RecordWriteArgs, fetch_records, v3_records, validate_caa_fields, validate_svcb_fields, validate_tlsa_fields, @@ -99,6 +99,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let domain = args.write.domain; let record_type = args.write.record_type; let name = args.write.name; + let domain_format = args.write.domain_format.form(); let replace_conflicting = args.replace_conflicting_types; validate_caa_fields(&record_type, &opts) .map_err(crate::error::GddyError::validation)?; @@ -143,13 +144,9 @@ pub(super) fn command() -> RuntimeCommandSpec { let desired = v3_records(&name, &record_type, &data, &opts); let plan = plan_set(&existing, &desired); if ctx.dry_run() { - return Ok(CommandResult::new(dry_run_set_preview( - &domain, - &record_type, - &name, - &plan, - )) - .with_dry_run()); + let mut preview = dry_run_set_preview(&domain, &record_type, &name, &plan); + domain_format.convert_dns_fields(&mut preview); + return Ok(CommandResult::new(preview).with_dry_run()); } let mut outcomes = Vec::new(); @@ -208,13 +205,7 @@ pub(super) fn command() -> RuntimeCommandSpec { } summarize_set_outcomes(&domain, &record_type, &name, &outcomes) - .map(|v| { - CommandResult::new(v).with_next_actions(vec![verify_with_list_action( - &domain, - &record_type, - &name, - )]) - }) + .map(|v| verified_result(domain_format, v, &domain, &record_type, &name)) .map_err(super::mutate_failed) }, ) diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index 60f5123b..b1086798 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -10,6 +10,7 @@ use domains_client::types; use super::common::{ api_error, fees_to_json, format_money, make_client, period_label, validate_domain_name, }; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -114,6 +115,9 @@ struct AvailableArgs { /// Optimize for speed (fast) or accuracy (full). #[arg(long = "check-type", value_name = "TYPE", value_parser = ["fast", "full"])] check_type: Option, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -178,6 +182,10 @@ pub(super) fn command() -> RuntimeCommandSpec { } } + let domain_format = args.domain_format.form(); + domain_format.convert_fields(&mut result, &["domain"]); + let resolved_domain = domain_format.render(&resolved_domain); + let cmd = CommandResult::new(result); if body.available.unwrap_or(false) { Ok(cmd.with_next_actions(vec![ diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 709ad8ce..ea056664 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -149,9 +149,9 @@ pub(super) fn period_label(period: u64) -> String { /// forbidden-host-code-point + IDNA checks, then layers RFC 1035/1123 shape /// rules a real domain always satisfies: not a bare IP literal, >=2 /// dot-separated LDH labels, each 1-63 bytes, total <=253 bytes, and a TLD -/// that isn't all-numeric (ICANN disallows those). Returns the original -/// trimmed input — not `Host::parse`'s ASCII/punycode form — so an -/// already-working Unicode domain's wire format is unchanged. +/// that isn't all-numeric (ICANN disallows those). Returns the ASCII +/// (punycode, lowercase) form `Host::parse` computed, so a name typed as +/// Unicode or pre-encoded reaches the API identically. pub(super) fn validate_domain_name(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -180,7 +180,7 @@ pub(super) fn validate_domain_name(raw: &str) -> Result { "{trimmed:?} doesn't look like a valid domain name (expected something like example.com)" ))); } - Ok(trimmed.to_owned()) + Ok(ascii) } /// Validate every value of a repeatable `--nameserver`-style flag as a @@ -709,15 +709,18 @@ mod tests { } #[test] - fn validate_domain_name_accepts_unicode_and_returns_original_not_punycode() { - // A real Unicode domain must pass (IDNA-processed for the shape check) - // but the returned string is the user's original input, not - // `Host::parse`'s ASCII/punycode form — no wire-format change for - // domains that already work today. - let input = "café.com"; + fn validate_domain_name_returns_punycode_for_unicode_input() { assert_eq!( - validate_domain_name(input).expect("valid unicode domain"), - input + validate_domain_name("münchen.de").expect("valid unicode domain"), + "xn--mnchen-3ya.de" + ); + } + + #[test] + fn validate_domain_name_accepts_pre_encoded_and_unicode_forms_alike() { + assert_eq!( + validate_domain_name("xn--mnchen-3ya.de").expect("valid punycode domain"), + validate_domain_name("münchen.de").expect("valid unicode domain"), ); } diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs index 7c5dd623..6aabd82f 100644 --- a/rust/src/domain/get.rs +++ b/rust/src/domain/get.rs @@ -7,6 +7,7 @@ use cli_engine::{ use domains_client::types; use super::common::{api_error, make_client, validate_domain_name}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; @@ -15,6 +16,9 @@ struct GetArgs { /// Domain to look up (must be in your account), e.g. example.com. #[arg(value_name = "DOMAIN")] domain: String, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -42,9 +46,10 @@ pub(super) fn command() -> RuntimeCommandSpec { Ok(r) => r.into_inner(), Err(e) => return Err(api_error("retrieving domain details", debug, e).await), }; - let value = serde_json::to_value(&detail).map_err(|e| { + let mut value = serde_json::to_value(&detail).map_err(|e| { CliCoreError::message(format!("failed to serialize domain details: {e}")) })?; + args.domain_format.form().convert_domain_fields(&mut value); Ok(CommandResult::new(value).with_next_actions(vec![ next_action("dns list ", "View this domain's DNS records") .with_param("domain", NextActionParam::required()), diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index 0a7255bc..7effdebe 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -9,6 +9,7 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, comma_joined, make_client}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; @@ -93,6 +94,9 @@ struct ListArgs { /// Include domains hidden by default, e.g. cancelled or confiscated. #[arg(long)] show_hidden: bool, + + #[command(flatten)] + domain_format: DomainFormArgs, } /// The result of looking for a `rel=next` link in a page's `links`. Per the @@ -269,13 +273,16 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or(usize::MAX) }); let items = fetch_domains(&client, &statuses, visible_only, stop_at, debug).await?; - let domains: Vec = items + let mut domains: Vec = items .iter() .map(serde_json::to_value) .collect::>() .map_err(|e| { CliCoreError::message(format!("failed to serialize domain list: {e}")) })?; + for domain in &mut domains { + args.domain_format.form().convert_domain_fields(domain); + } Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ next_action("dns list ", "View a domain's DNS records") .with_param("domain", NextActionParam::required()), diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index 739c29d2..d1760a2b 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -9,6 +9,7 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, make_client, validate_domain_name, validate_nameserver_hosts}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_NAMESERVER_UPDATE; @@ -30,6 +31,9 @@ struct NameserversSetArgs { /// Nameserver host (repeatable; replaces the full set). #[arg(long, value_name = "HOST", required = true)] nameserver: Vec, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn group() -> RuntimeGroupSpec { @@ -89,9 +93,13 @@ pub(super) fn group() -> RuntimeGroupSpec { if let Some(status) = op.status.as_ref() { result["status"] = json!(status.to_string()); } + let domain_format = args.domain_format.form(); + domain_format.convert_domain_fields(&mut result); Ok(CommandResult::new(result).with_next_actions(vec![ - next_action("domain get ", "Confirm the updated nameservers") - .with_param("domain", NextActionParam::value(domain)), + next_action("domain get ", "Confirm the updated nameservers").with_param( + "domain", + NextActionParam::value(domain_format.render(&domain)), + ), ])) }, )) diff --git a/rust/src/domain/operation.rs b/rust/src/domain/operation.rs index c00945fc..f21fdd41 100644 --- a/rust/src/domain/operation.rs +++ b/rust/src/domain/operation.rs @@ -14,6 +14,7 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, format_operation_error, is_terminal_status, make_client}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -34,6 +35,9 @@ struct OperationStatusArgs { /// The operationId returned by an async domain mutation. #[arg(value_name = "OPERATION_ID")] operation_id: String, + + #[command(flatten)] + domain_format: DomainFormArgs, } fn status_command() -> RuntimeCommandSpec { @@ -81,6 +85,8 @@ fn status_command() -> RuntimeCommandSpec { .unwrap_or(operation_id); let op_type = op.type_.as_ref().map(|t| t.to_string()).unwrap_or_default(); + let domain_format = args.domain_format.form(); + let domain = domain_format.render(&domain); let mut result = json!({ "operationId": op_id, "type": op_type, diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 19740eb1..852d351d 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -10,6 +10,7 @@ use domains_client::types; use super::common::{api_error, format_operation_error, is_terminal_status, make_client_with_cred}; use crate::domain::AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS; use crate::error::GddyError; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; @@ -132,6 +133,9 @@ struct PurchaseArgs { /// Confirm the purchase; required because it charges your account. #[arg(long)] confirm: bool, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -402,6 +406,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } } + args.domain_format.form().convert_domain_fields(&mut result); Ok(CommandResult::new(result).with_next_actions(actions)) }, ) diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 1ed9e4dc..944ca804 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -14,6 +14,7 @@ use super::common::{ }; use crate::domain::AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS; use crate::error::GddyError; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -239,6 +240,9 @@ struct QuoteArgs { /// Custom nameserver (repeatable); omit to use GoDaddy defaults. #[arg(long, value_name = "HOST")] nameserver: Vec, + + #[command(flatten)] + domain_format: DomainFormArgs, } /// `requiredAgreements`/`resolved` aren't in the default fields (the @@ -346,7 +350,7 @@ pub(super) fn command() -> RuntimeCommandSpec { Err(e) => return Err(api_error("quoting registration", debug, e).await), }; - let view = quote_to_json("e, &domain); + let mut view = quote_to_json("e, &domain); // Persist the quote so `purchase --quote-token` can reproduce this // exact request (only possible when it's available and got a token). @@ -406,13 +410,17 @@ pub(super) fn command() -> RuntimeCommandSpec { // same next step `domain available` offers for a taken name. // `domain suggest` accepts a seed domain, so the domain just // quoted is a valid query to copy/paste directly. - let resolved_domain = quote.domain.clone().unwrap_or_else(|| domain.clone()); + let resolved_domain = args + .domain_format + .form() + .render("e.domain.clone().unwrap_or_else(|| domain.clone())); next_actions.push( next_action("domain suggest ", "Find an available alternative") .with_param("query", NextActionParam::value(resolved_domain)), ); } + args.domain_format.form().convert_domain_fields(&mut view); Ok(CommandResult::new(view).with_next_actions(next_actions)) }, ) diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 49b04218..d2ddaace 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -10,6 +10,7 @@ use domains_client::types; use super::common::{ api_error, comma_joined, fees_to_json, format_money, make_client, term_for_period, }; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -162,6 +163,9 @@ struct SuggestArgs { /// Only suggest names at most this many characters. #[arg(long = "length-max", value_name = "N", value_parser = clap::value_parser!(i64).range(1..))] length_max: Option, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -205,8 +209,16 @@ pub(super) fn command() -> RuntimeCommandSpec { Ok(r) => r.into_inner(), Err(e) => return Err(api_error("domain suggestion", debug, e).await), }; - let suggestions: Vec = - resp.items.iter().filter_map(suggestion_to_json).collect(); + let domain_format = args.domain_format.form(); + let suggestions: Vec = resp + .items + .iter() + .filter_map(suggestion_to_json) + .map(|mut s| { + domain_format.convert_domain_fields(&mut s); + s + }) + .collect(); Ok( CommandResult::new(json!(suggestions)).with_next_actions(vec![ next_action("domain available ", "Check a suggested domain") diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs index b0ab4ca9..4b835987 100644 --- a/rust/src/hosting/domain/attach.rs +++ b/rust/src/hosting/domain/attach.rs @@ -1,6 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use crate::hosting::common::{HostingDomain, client_err, make_client}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::scopes::HOSTING_DOMAIN_WRITE as DOMAIN_WRITE; @@ -11,8 +12,16 @@ struct DomainAttachArgs { app_id: String, /// Fully-qualified domain name to attach (e.g. www.example.com). - #[arg(long, value_name = "HOSTNAME")] + #[arg(long, value_name = "HOSTNAME", value_parser = parse_hostname_arg)] hostname: String, + + #[command(flatten)] + domain_format: DomainFormArgs, +} + +/// Clap value-parser: a Unicode hostname is sent in its ASCII (punycode) form. +fn parse_hostname_arg(raw: &str) -> Result { + crate::idn::to_ascii(raw).map_err(|e| e.to_string()) } pub(super) fn command() -> RuntimeCommandSpec { @@ -33,10 +42,11 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx, args: DomainAttachArgs| async move { let app_id = args.app_id.clone(); let client = make_client(&ctx, &[DOMAIN_WRITE]).await?; - let data = client + let mut data = client .attach_domain(&app_id, &args.hostname) .await .map_err(client_err)?; + args.domain_format.form().convert_domain_fields(&mut data); Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "hosting domain list --app-id ", diff --git a/rust/src/hosting/domain/get.rs b/rust/src/hosting/domain/get.rs index b37b0079..4cadca53 100644 --- a/rust/src/hosting/domain/get.rs +++ b/rust/src/hosting/domain/get.rs @@ -1,6 +1,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use crate::hosting::common::{HostingDomain, client_err, make_client}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::scopes::HOSTING_DOMAIN_READ as DOMAIN_READ; @@ -13,6 +14,9 @@ struct DomainGetArgs { /// Domain ID. #[arg(long = "domain-id", value_name = "DOMAIN_ID")] domain_id: String, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -31,10 +35,11 @@ pub(super) fn command() -> RuntimeCommandSpec { let app_id = args.app_id.clone(); let domain_id = args.domain_id.clone(); let client = make_client(&ctx, &[DOMAIN_READ]).await?; - let data = client + let mut data = client .get_domain(&app_id, &domain_id) .await .map_err(client_err)?; + args.domain_format.form().convert_domain_fields(&mut data); Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "hosting domain detach --app-id --domain-id ", diff --git a/rust/src/hosting/domain/list.rs b/rust/src/hosting/domain/list.rs index 80124f6e..4bfdd723 100644 --- a/rust/src/hosting/domain/list.rs +++ b/rust/src/hosting/domain/list.rs @@ -2,6 +2,7 @@ use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec use serde_json::{Value, json}; use crate::hosting::common::{HostingDomainSummary, client_err, make_client, next_page_token}; +use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::scopes::HOSTING_DOMAIN_READ as DOMAIN_READ; @@ -14,6 +15,9 @@ struct DomainListArgs { /// Maximum number of domains to return. Omit to return all. #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] limit: Option, + + #[command(flatten)] + domain_format: DomainFormArgs, } pub(super) fn command() -> RuntimeCommandSpec { @@ -60,6 +64,9 @@ pub(super) fn command() -> RuntimeCommandSpec { } } + for item in &mut all_items { + args.domain_format.form().convert_domain_fields(item); + } Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ next_action( "hosting domain attach --app-id --hostname ", diff --git a/rust/src/idn.rs b/rust/src/idn.rs new file mode 100644 index 00000000..babae7af --- /dev/null +++ b/rust/src/idn.rs @@ -0,0 +1,228 @@ +//! Internationalized domain names: Unicode for people, punycode for the wire. +//! +//! Commands accept a name in either form and send the ASCII (punycode) form to +//! the API ([`to_ascii`]). Names shown to the user are Unicode by default and +//! ASCII when the user asks for it ([`DomainForm`]). + +use serde_json::Value; + +use crate::error::GddyError; + +/// Prefix of an ASCII-compatible-encoded (punycode) label, any case. +const ACE_PREFIX: &str = "xn--"; + +/// The ASCII (punycode) form of a domain name or DNS record name. +/// +/// Pure-ASCII input is returned untouched, so names the API already accepts — +/// including `@`, `*` and `_dmarc`-style labels — never change. Anything else +/// is run through IDNA, which also lowercases it. +pub(crate) fn to_ascii(name: &str) -> Result { + if name.is_ascii() { + return Ok(name.to_owned()); + } + idna::domain_to_ascii(name).map_err(|_| { + GddyError::validation(format!("{name:?} is not a valid internationalized name")) + .with_fix("Remove any characters that can't appear in a domain name.") + }) +} + +/// The Unicode form of `name`. Labels that are not valid punycode are left as +/// they are, so display never fails. +pub(crate) fn to_unicode(name: &str) -> String { + if !has_ace_label(name) { + return name.to_owned(); + } + match idna::domain_to_unicode(name) { + (unicode, Ok(())) => unicode, + (_, Err(_)) => name.to_owned(), + } +} + +fn has_ace_label(name: &str) -> bool { + name.split('.').any(|label| { + label + .get(..ACE_PREFIX.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(ACE_PREFIX)) + }) +} + +/// Response fields that hold a domain name or a list of host names, across the +/// domain and hosting APIs. +const DOMAIN_FIELDS: &[&str] = &[ + "domain", + "idnDomain", + "unicodeDomain", + "nameServers", + "nameservers", + "hostname", +]; + +/// Fields of a DNS command's output that hold a zone or a record name. +const DNS_FIELDS: &[&str] = &["domain", "name"]; + +/// Which form of a domain name to show. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum DomainForm { + #[default] + Unicode, + Punycode, +} + +impl DomainForm { + pub(crate) fn from_flag(punycode: bool) -> Self { + if punycode { + Self::Punycode + } else { + Self::Unicode + } + } + + /// `name` in this form. A name that cannot be converted is shown as-is. + pub(crate) fn render(self, name: &str) -> String { + match self { + Self::Unicode => to_unicode(name), + Self::Punycode => to_ascii(name).unwrap_or_else(|_| name.to_owned()), + } + } + + /// Convert, in place, every string under a key in `keys` — at any depth, + /// including strings inside arrays under such a key — to this form. + pub(crate) fn convert_fields(self, value: &mut Value, keys: &[&str]) { + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + if keys.contains(&key.as_str()) { + self.convert_all(child); + } else { + self.convert_fields(child, keys); + } + } + } + Value::Array(items) => { + for item in items { + self.convert_fields(item, keys); + } + } + _ => {} + } + } + + /// [`Self::convert_fields`] over the standard domain-name response fields. + pub(crate) fn convert_domain_fields(self, value: &mut Value) { + self.convert_fields(value, DOMAIN_FIELDS); + } + + /// [`Self::convert_fields`] over the zone and record-name fields of DNS output. + pub(crate) fn convert_dns_fields(self, value: &mut Value) { + self.convert_fields(value, DNS_FIELDS); + } + + fn convert_all(self, value: &mut Value) { + match value { + Value::String(s) => *s = self.render(s), + Value::Array(items) => items.iter_mut().for_each(|v| self.convert_all(v)), + _ => {} + } + } +} + +/// `--punycode`, shared by every command that shows domain names. +#[derive(Debug, Clone, Copy, Default, clap::Args)] +pub(crate) struct DomainFormArgs { + /// Show domain names in their ASCII (punycode) form, e.g. xn--mnchen-3ya.de, instead of Unicode. + #[arg(long)] + punycode: bool, +} + +impl DomainFormArgs { + pub(crate) fn form(&self) -> DomainForm { + DomainForm::from_flag(self.punycode) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn to_ascii_encodes_unicode_and_leaves_ascii_alone() { + assert_eq!( + to_ascii("münchen.de").expect("valid name"), + "xn--mnchen-3ya.de" + ); + assert_eq!( + to_ascii("xn--mnchen-3ya.de").expect("valid name"), + "xn--mnchen-3ya.de" + ); + assert_eq!(to_ascii("Example.COM").expect("valid name"), "Example.COM"); + for special in ["@", "*", "_dmarc", "www"] { + assert_eq!(to_ascii(special).expect("valid name"), special); + } + } + + #[test] + fn to_ascii_encodes_only_the_unicode_labels_of_a_dns_name() { + assert_eq!( + to_ascii("*.münchen").expect("valid name"), + "*.xn--mnchen-3ya" + ); + } + + #[test] + fn to_ascii_rejects_names_idna_disallows() { + assert!(to_ascii("\u{301}abc.com").is_err()); + } + + #[test] + fn to_unicode_decodes_punycode_labels() { + assert_eq!(to_unicode("xn--mnchen-3ya.de"), "münchen.de"); + assert_eq!(to_unicode("XN--MNCHEN-3YA.de"), "münchen.de"); + assert_eq!(to_unicode("www.xn--mnchen-3ya.de"), "www.münchen.de"); + } + + #[test] + fn to_unicode_leaves_plain_and_undecodable_names_alone() { + assert_eq!(to_unicode("example.com"), "example.com"); + assert_eq!(to_unicode("münchen.de"), "münchen.de"); + assert_eq!(to_unicode("@"), "@"); + assert_eq!(to_unicode("xn--.com"), "xn--.com"); + assert_eq!(to_unicode("xn--a-.com"), "xn--a-.com"); + } + + #[test] + fn render_honors_the_requested_form() { + assert_eq!( + DomainForm::Unicode.render("xn--mnchen-3ya.de"), + "münchen.de" + ); + assert_eq!( + DomainForm::Punycode.render("münchen.de"), + "xn--mnchen-3ya.de" + ); + assert_eq!(DomainForm::Punycode.render("example.com"), "example.com"); + } + + #[test] + fn convert_fields_walks_nested_values_and_string_arrays() { + let mut v = json!({ + "domain": "xn--mnchen-3ya.de", + "status": "xn--mnchen-3ya.de", + "nameServers": ["ns1.xn--mnchen-3ya.de", "ns2.example.com"], + "items": [{"name": "xn--mnchen-3ya", "type": "A"}], + "count": 3, + }); + DomainForm::Unicode.convert_fields(&mut v, &["domain", "nameServers", "name"]); + assert_eq!( + v, + json!({ + "domain": "münchen.de", + "status": "xn--mnchen-3ya.de", + "nameServers": ["ns1.münchen.de", "ns2.example.com"], + "items": [{"name": "münchen", "type": "A"}], + "count": 3, + }) + ); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 9ca6b6c2..edc916d0 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -10,6 +10,7 @@ mod environments; mod error; mod hosting; mod http; +mod idn; mod malware; mod next_action; mod output_schema; From d884bdd42fc0cd445f59b3ab32461a25eb3eccf7 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Thu, 22 Oct 2026 15:50:38 -0700 Subject: [PATCH 2/3] fix(domain): lowercase every name and convert only known output fields - DNS record names, zones and hosting hostnames are lowercased before they are sent, matching the domain commands. - --punycode only changes the fields each command is known to hold domain names in. The API's own idnDomain and unicodeDomain fields are left as returned. Co-Authored-By: Claude Sonnet 5.5 --- rust/src/dns/delete.rs | 5 +- rust/src/dns/list.rs | 2 +- rust/src/dns/records.rs | 5 +- rust/src/dns/set/mod.rs | 4 +- rust/src/domain/get.rs | 4 +- rust/src/domain/list.rs | 4 +- rust/src/domain/nameservers.rs | 2 +- rust/src/domain/purchase.rs | 4 +- rust/src/domain/quote.rs | 2 +- rust/src/domain/suggest.rs | 2 +- rust/src/hosting/domain/attach.rs | 4 +- rust/src/hosting/domain/get.rs | 4 +- rust/src/hosting/domain/list.rs | 4 +- rust/src/idn.rs | 115 ++++++++++++++++-------------- 14 files changed, 93 insertions(+), 68 deletions(-) diff --git a/rust/src/dns/delete.rs b/rust/src/dns/delete.rs index da1383cf..2fdf8eba 100644 --- a/rust/src/dns/delete.rs +++ b/rust/src/dns/delete.rs @@ -11,7 +11,8 @@ use crate::scopes::DOMAINS_DNS_UPDATE; use domains_client::types; use super::records::{ - fetch_records, parse_dns_name_arg, parse_write_type_arg, record_value, verified_result, + ZONE_AND_NAME, fetch_records, parse_dns_name_arg, parse_write_type_arg, record_value, + verified_result, }; output_schema!(DnsDeleteResult { @@ -193,7 +194,7 @@ pub(super) fn command() -> RuntimeCommandSpec { if ctx.dry_run() { let mut preview = dry_run_delete_preview(&domain, &record_type, &name, &existing); - domain_format.convert_dns_fields(&mut preview); + domain_format.convert_fields(&mut preview, ZONE_AND_NAME); return Ok(CommandResult::new(preview).with_dry_run()); } diff --git a/rust/src/dns/list.rs b/rust/src/dns/list.rs index 2fa0c1d2..3689a306 100644 --- a/rust/src/dns/list.rs +++ b/rust/src/dns/list.rs @@ -93,7 +93,7 @@ pub(super) fn command() -> RuntimeCommandSpec { CliCoreError::message(format!("failed to serialize DNS records: {e}")) })?; for record in &mut out { - args.domain_format.form().convert_dns_fields(record); + args.domain_format.form().convert_fields(record, &["name"]); } Ok(CommandResult::new(json!(out))) }, diff --git a/rust/src/dns/records.rs b/rust/src/dns/records.rs index 8a2de4e6..1b11250b 100644 --- a/rust/src/dns/records.rs +++ b/rust/src/dns/records.rs @@ -25,6 +25,9 @@ pub(super) const LISTABLE_TYPES: &[&str] = &[ ]; /// Default TTL (seconds) for `dns add`/`set` when `--ttl` is omitted (v3 requires a ttl). pub(super) const DEFAULT_TTL: i64 = 3600; +/// The output fields of a DNS write that hold the zone and the record name. +pub(super) const ZONE_AND_NAME: &[&str] = &["domain", "name"]; + /// Page size for the paginated v3 list; the handler pages through until every /// matching record is collected. const LIST_PAGE_SIZE: std::num::NonZeroU64 = @@ -442,7 +445,7 @@ pub(super) fn verified_result( record_type: &str, name: &str, ) -> CommandResult { - domain_format.convert_dns_fields(&mut payload); + domain_format.convert_fields(&mut payload, ZONE_AND_NAME); CommandResult::new(payload).with_next_actions(vec![verify_with_list_action( &domain_format.render(domain), record_type, diff --git a/rust/src/dns/set/mod.rs b/rust/src/dns/set/mod.rs index dd5d4e83..fd4fe040 100644 --- a/rust/src/dns/set/mod.rs +++ b/rust/src/dns/set/mod.rs @@ -6,11 +6,11 @@ use crate::domain::{api_error, make_client}; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_DNS_UPDATE; -use super::records::verified_result; use super::records::{ RecordOptions, RecordWriteArgs, fetch_records, v3_records, validate_caa_fields, validate_svcb_fields, validate_tlsa_fields, }; +use super::records::{ZONE_AND_NAME, verified_result}; mod outcome; mod plan; @@ -145,7 +145,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let plan = plan_set(&existing, &desired); if ctx.dry_run() { let mut preview = dry_run_set_preview(&domain, &record_type, &name, &plan); - domain_format.convert_dns_fields(&mut preview); + domain_format.convert_fields(&mut preview, ZONE_AND_NAME); return Ok(CommandResult::new(preview).with_dry_run()); } diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs index 6aabd82f..31b8b9fb 100644 --- a/rust/src/domain/get.rs +++ b/rust/src/domain/get.rs @@ -49,7 +49,9 @@ pub(super) fn command() -> RuntimeCommandSpec { let mut value = serde_json::to_value(&detail).map_err(|e| { CliCoreError::message(format!("failed to serialize domain details: {e}")) })?; - args.domain_format.form().convert_domain_fields(&mut value); + args.domain_format + .form() + .convert_fields(&mut value, &["domain", "nameServers"]); Ok(CommandResult::new(value).with_next_actions(vec![ next_action("dns list ", "View this domain's DNS records") .with_param("domain", NextActionParam::required()), diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index 7effdebe..957fc854 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -281,7 +281,9 @@ pub(super) fn command() -> RuntimeCommandSpec { CliCoreError::message(format!("failed to serialize domain list: {e}")) })?; for domain in &mut domains { - args.domain_format.form().convert_domain_fields(domain); + args.domain_format + .form() + .convert_fields(domain, &["domain", "nameServers"]); } Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ next_action("dns list ", "View a domain's DNS records") diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index d1760a2b..ca100d6d 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -94,7 +94,7 @@ pub(super) fn group() -> RuntimeGroupSpec { result["status"] = json!(status.to_string()); } let domain_format = args.domain_format.form(); - domain_format.convert_domain_fields(&mut result); + domain_format.convert_fields(&mut result, &["domain", "nameservers"]); Ok(CommandResult::new(result).with_next_actions(vec![ next_action("domain get ", "Confirm the updated nameservers").with_param( "domain", diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 852d351d..c018ee82 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -406,7 +406,9 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } } - args.domain_format.form().convert_domain_fields(&mut result); + args.domain_format + .form() + .convert_fields(&mut result, &["domain"]); Ok(CommandResult::new(result).with_next_actions(actions)) }, ) diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 944ca804..f876f76d 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -420,7 +420,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } - args.domain_format.form().convert_domain_fields(&mut view); + args.domain_format.form().convert_fields(&mut view, &["domain", "resolved.nameServers"]); Ok(CommandResult::new(view).with_next_actions(next_actions)) }, ) diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index d2ddaace..e56fa0e4 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -215,7 +215,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .iter() .filter_map(suggestion_to_json) .map(|mut s| { - domain_format.convert_domain_fields(&mut s); + domain_format.convert_fields(&mut s, &["domain"]); s }) .collect(); diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs index 4b835987..43f04680 100644 --- a/rust/src/hosting/domain/attach.rs +++ b/rust/src/hosting/domain/attach.rs @@ -46,7 +46,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .attach_domain(&app_id, &args.hostname) .await .map_err(client_err)?; - args.domain_format.form().convert_domain_fields(&mut data); + args.domain_format + .form() + .convert_fields(&mut data, &["hostname"]); Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "hosting domain list --app-id ", diff --git a/rust/src/hosting/domain/get.rs b/rust/src/hosting/domain/get.rs index 4cadca53..99494073 100644 --- a/rust/src/hosting/domain/get.rs +++ b/rust/src/hosting/domain/get.rs @@ -39,7 +39,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .get_domain(&app_id, &domain_id) .await .map_err(client_err)?; - args.domain_format.form().convert_domain_fields(&mut data); + args.domain_format + .form() + .convert_fields(&mut data, &["hostname"]); Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "hosting domain detach --app-id --domain-id ", diff --git a/rust/src/hosting/domain/list.rs b/rust/src/hosting/domain/list.rs index 4bfdd723..268944e1 100644 --- a/rust/src/hosting/domain/list.rs +++ b/rust/src/hosting/domain/list.rs @@ -65,7 +65,9 @@ pub(super) fn command() -> RuntimeCommandSpec { } for item in &mut all_items { - args.domain_format.form().convert_domain_fields(item); + args.domain_format + .form() + .convert_fields(item, &["hostname"]); } Ok(CommandResult::new(json!(all_items)).with_next_actions(vec![ next_action( diff --git a/rust/src/idn.rs b/rust/src/idn.rs index babae7af..b48d040c 100644 --- a/rust/src/idn.rs +++ b/rust/src/idn.rs @@ -13,12 +13,13 @@ const ACE_PREFIX: &str = "xn--"; /// The ASCII (punycode) form of a domain name or DNS record name. /// -/// Pure-ASCII input is returned untouched, so names the API already accepts — -/// including `@`, `*` and `_dmarc`-style labels — never change. Anything else -/// is run through IDNA, which also lowercases it. +/// The result is always lowercase, since domain names are case-insensitive. +/// Pure-ASCII input is otherwise returned as typed, so names the API already +/// accepts — including `@`, `*` and `_dmarc`-style labels — never change. +/// Anything else is run through IDNA. pub(crate) fn to_ascii(name: &str) -> Result { if name.is_ascii() { - return Ok(name.to_owned()); + return Ok(name.to_ascii_lowercase()); } idna::domain_to_ascii(name).map_err(|_| { GddyError::validation(format!("{name:?} is not a valid internationalized name")) @@ -46,20 +47,6 @@ fn has_ace_label(name: &str) -> bool { }) } -/// Response fields that hold a domain name or a list of host names, across the -/// domain and hosting APIs. -const DOMAIN_FIELDS: &[&str] = &[ - "domain", - "idnDomain", - "unicodeDomain", - "nameServers", - "nameservers", - "hostname", -]; - -/// Fields of a DNS command's output that hold a zone or a record name. -const DNS_FIELDS: &[&str] = &["domain", "name"]; - /// Which form of a domain name to show. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) enum DomainForm { @@ -85,42 +72,27 @@ impl DomainForm { } } - /// Convert, in place, every string under a key in `keys` — at any depth, - /// including strings inside arrays under such a key — to this form. - pub(crate) fn convert_fields(self, value: &mut Value, keys: &[&str]) { - match value { - Value::Object(map) => { - for (key, child) in map.iter_mut() { - if keys.contains(&key.as_str()) { - self.convert_all(child); - } else { - self.convert_fields(child, keys); - } - } + /// Convert to this form the value at each dotted path in `paths` (for + /// example `"domain"` or `"resolved.nameServers"`). A path names a string + /// or an array of strings reached through objects only; a path that is not + /// present, or leads anywhere else, is left alone. Callers list exactly + /// the fields their command's output holds domain names in, and loop over + /// list items themselves. + pub(crate) fn convert_fields(self, value: &mut Value, paths: &[&str]) { + for path in paths { + let target = path + .split('.') + .try_fold(&mut *value, |node, key| node.get_mut(key)); + if let Some(target) = target { + self.convert_names(target); } - Value::Array(items) => { - for item in items { - self.convert_fields(item, keys); - } - } - _ => {} } } - /// [`Self::convert_fields`] over the standard domain-name response fields. - pub(crate) fn convert_domain_fields(self, value: &mut Value) { - self.convert_fields(value, DOMAIN_FIELDS); - } - - /// [`Self::convert_fields`] over the zone and record-name fields of DNS output. - pub(crate) fn convert_dns_fields(self, value: &mut Value) { - self.convert_fields(value, DNS_FIELDS); - } - - fn convert_all(self, value: &mut Value) { + fn convert_names(self, value: &mut Value) { match value { Value::String(s) => *s = self.render(s), - Value::Array(items) => items.iter_mut().for_each(|v| self.convert_all(v)), + Value::Array(items) => items.iter_mut().for_each(|v| self.convert_names(v)), _ => {} } } @@ -156,12 +128,23 @@ mod tests { to_ascii("xn--mnchen-3ya.de").expect("valid name"), "xn--mnchen-3ya.de" ); - assert_eq!(to_ascii("Example.COM").expect("valid name"), "Example.COM"); for special in ["@", "*", "_dmarc", "www"] { assert_eq!(to_ascii(special).expect("valid name"), special); } } + #[test] + fn to_ascii_lowercases_ascii_and_unicode_names_alike() { + assert_eq!( + to_ascii("WWW.Example.COM").expect("valid name"), + "www.example.com" + ); + assert_eq!( + to_ascii("MÜNCHEN.de").expect("valid name"), + "xn--mnchen-3ya.de" + ); + } + #[test] fn to_ascii_encodes_only_the_unicode_labels_of_a_dns_name() { assert_eq!( @@ -205,24 +188,50 @@ mod tests { } #[test] - fn convert_fields_walks_nested_values_and_string_arrays() { + fn convert_fields_converts_only_the_listed_paths() { let mut v = json!({ "domain": "xn--mnchen-3ya.de", + "idnDomain": "xn--mnchen-3ya.de", "status": "xn--mnchen-3ya.de", "nameServers": ["ns1.xn--mnchen-3ya.de", "ns2.example.com"], - "items": [{"name": "xn--mnchen-3ya", "type": "A"}], + "resolved": { + "nameServers": ["ns1.xn--mnchen-3ya.de"], + "name": "xn--mnchen-3ya", + }, + "items": [{"domain": "xn--mnchen-3ya.de"}], "count": 3, }); - DomainForm::Unicode.convert_fields(&mut v, &["domain", "nameServers", "name"]); + DomainForm::Unicode.convert_fields( + &mut v, + &[ + "domain", + "nameServers", + "resolved.nameServers", + "missing.path", + ], + ); assert_eq!( v, json!({ "domain": "münchen.de", + "idnDomain": "xn--mnchen-3ya.de", "status": "xn--mnchen-3ya.de", "nameServers": ["ns1.münchen.de", "ns2.example.com"], - "items": [{"name": "münchen", "type": "A"}], + "resolved": { + "nameServers": ["ns1.münchen.de"], + "name": "xn--mnchen-3ya", + }, + "items": [{"domain": "xn--mnchen-3ya.de"}], "count": 3, }) ); } + + #[test] + fn convert_fields_ignores_paths_that_do_not_lead_to_names() { + let mut v = json!({"domain": 7, "nameServers": {"a": "xn--mnchen-3ya.de"}, "items": ["xn--mnchen-3ya.de"]}); + let before = v.clone(); + DomainForm::Unicode.convert_fields(&mut v, &["domain", "nameServers.a.b", "items.x"]); + assert_eq!(v, before); + } } From 2e42e3a102bee2c7540a884b094738a77675e009 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Thu, 22 Oct 2026 15:52:50 -0700 Subject: [PATCH 3/3] fix(domain): share one name parser and show how to fix an invalid name - Zone, record name and hostname arguments use one parser, so an invalid international name reports what to change as well as what is wrong. --- rust/src/dns/delete.rs | 7 ++--- rust/src/dns/list.rs | 6 ++-- rust/src/dns/records.rs | 46 ++++++++----------------------- rust/src/hosting/domain/attach.rs | 7 +---- rust/src/idn.rs | 26 +++++++++++++++++ 5 files changed, 45 insertions(+), 47 deletions(-) diff --git a/rust/src/dns/delete.rs b/rust/src/dns/delete.rs index 2fdf8eba..d6af8937 100644 --- a/rust/src/dns/delete.rs +++ b/rust/src/dns/delete.rs @@ -11,8 +11,7 @@ use crate::scopes::DOMAINS_DNS_UPDATE; use domains_client::types; use super::records::{ - ZONE_AND_NAME, fetch_records, parse_dns_name_arg, parse_write_type_arg, record_value, - verified_result, + ZONE_AND_NAME, fetch_records, parse_write_type_arg, record_value, verified_result, }; output_schema!(DnsDeleteResult { @@ -118,7 +117,7 @@ fn dry_run_delete_preview( #[derive(Debug, Clone, clap::Args)] struct DeleteArgs { /// Domain whose records to delete (e.g. example.com). - #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] + #[arg(value_name = "DOMAIN", value_parser = crate::idn::parse_name_arg)] domain: String, /// Record type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, SVCB, TLSA, TXT). @@ -126,7 +125,7 @@ struct DeleteArgs { record_type: String, /// Record name relative to the domain (e.g. www). - #[arg(long, value_name = "NAME", value_parser = parse_dns_name_arg)] + #[arg(long, value_name = "NAME", value_parser = crate::idn::parse_name_arg)] name: String, #[command(flatten)] diff --git a/rust/src/dns/list.rs b/rust/src/dns/list.rs index 3689a306..d370785f 100644 --- a/rust/src/dns/list.rs +++ b/rust/src/dns/list.rs @@ -12,12 +12,12 @@ use crate::scopes::DOMAINS_READ; use domains_client::types; -use super::records::{fetch_records, merged_tlsa_data, parse_dns_name_arg, parse_list_type_arg}; +use super::records::{fetch_records, merged_tlsa_data, parse_list_type_arg}; #[derive(Debug, Clone, clap::Args)] struct ListArgs { /// Domain whose records to list (e.g. example.com). - #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] + #[arg(value_name = "DOMAIN", value_parser = crate::idn::parse_name_arg)] domain: String, /// Only records of this type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, NS, @@ -26,7 +26,7 @@ struct ListArgs { record_type: Option, /// Only records with this name (requires --type). - #[arg(long, value_name = "NAME", requires = "record_type", value_parser = parse_dns_name_arg)] + #[arg(long, value_name = "NAME", requires = "record_type", value_parser = crate::idn::parse_name_arg)] name: Option, #[command(flatten)] diff --git a/rust/src/dns/records.rs b/rust/src/dns/records.rs index 1b11250b..68adc683 100644 --- a/rust/src/dns/records.rs +++ b/rust/src/dns/records.rs @@ -37,12 +37,6 @@ const LIST_PAGE_SIZE: std::num::NonZeroU64 = /// against [`WRITABLE_TYPES`] and return the canonical upper-case wire string. /// The read-only NS/SOA get a clear "managed by GoDaddy" reason. Validating in /// clap rejects invalid input at parse time — before `--dry-run`/auth short-circuits. -/// Clap value-parser for a zone or record name: Unicode names are sent to the -/// API in their ASCII (punycode) form; ASCII names pass through unchanged. -pub(super) fn parse_dns_name_arg(raw: &str) -> Result { - crate::idn::to_ascii(raw).map_err(|e| e.to_string()) -} - pub(super) fn parse_write_type_arg(raw: &str) -> Result { let upper = raw.to_ascii_uppercase(); if WRITABLE_TYPES.contains(&upper.as_str()) { @@ -129,7 +123,7 @@ pub(crate) const CONDITIONALLY_REQUIRED_FLAGS: &[(&str, &str)] = &[ #[derive(Debug, Clone, clap::Args)] pub(super) struct RecordWriteArgs { /// Domain whose records to modify (e.g. example.com). - #[arg(value_name = "DOMAIN", value_parser = parse_dns_name_arg)] + #[arg(value_name = "DOMAIN", value_parser = crate::idn::parse_name_arg)] pub(super) domain: String, // `ignore_case` is load-bearing, not cosmetic. The `required_if_eq` predicates @@ -142,7 +136,7 @@ pub(super) struct RecordWriteArgs { pub(super) record_type: String, /// Record name relative to the domain (e.g. www, @ for the apex). - #[arg(long, value_name = "NAME", value_parser = parse_dns_name_arg)] + #[arg(long, value_name = "NAME", value_parser = crate::idn::parse_name_arg)] pub(super) name: String, #[command(flatten)] @@ -436,6 +430,16 @@ pub(super) async fn fetch_records( /// Next action pointing back at `dns list` to verify a write, pre-filled with /// the domain/type/name the write just touched. +pub(super) fn verify_with_list_action(domain: &str, record_type: &str, name: &str) -> NextAction { + next_action( + "dns list --type --name ", + "Verify the records for this type+name", + ) + .with_param("domain", NextActionParam::value(domain)) + .with_param("type", NextActionParam::value(record_type)) + .with_param("name", NextActionParam::value(name)) +} + /// A mutation's success payload plus the "verify with `dns list`" next action, /// with the zone and record name shown in `domain_format`. pub(super) fn verified_result( @@ -453,36 +457,10 @@ pub(super) fn verified_result( )]) } -pub(super) fn verify_with_list_action(domain: &str, record_type: &str, name: &str) -> NextAction { - next_action( - "dns list --type --name ", - "Verify the records for this type+name", - ) - .with_param("domain", NextActionParam::value(domain)) - .with_param("type", NextActionParam::value(record_type)) - .with_param("name", NextActionParam::value(name)) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn parse_dns_name_arg_encodes_unicode_and_keeps_special_names() { - assert_eq!( - parse_dns_name_arg("münchen.de").expect("valid name"), - "xn--mnchen-3ya.de" - ); - assert_eq!( - parse_dns_name_arg("*.münchen").expect("valid name"), - "*.xn--mnchen-3ya" - ); - for plain in ["example.com", "xn--mnchen-3ya.de", "@", "www", "_dmarc"] { - assert_eq!(parse_dns_name_arg(plain).expect("valid name"), plain); - } - assert!(parse_dns_name_arg("\u{301}abc.com").is_err()); - } - #[test] fn verified_result_shows_the_requested_form_of_zone_and_name() { let payload = || { diff --git a/rust/src/hosting/domain/attach.rs b/rust/src/hosting/domain/attach.rs index 43f04680..73e621c4 100644 --- a/rust/src/hosting/domain/attach.rs +++ b/rust/src/hosting/domain/attach.rs @@ -12,18 +12,13 @@ struct DomainAttachArgs { app_id: String, /// Fully-qualified domain name to attach (e.g. www.example.com). - #[arg(long, value_name = "HOSTNAME", value_parser = parse_hostname_arg)] + #[arg(long, value_name = "HOSTNAME", value_parser = crate::idn::parse_name_arg)] hostname: String, #[command(flatten)] domain_format: DomainFormArgs, } -/// Clap value-parser: a Unicode hostname is sent in its ASCII (punycode) form. -fn parse_hostname_arg(raw: &str) -> Result { - crate::idn::to_ascii(raw).map_err(|e| e.to_string()) -} - pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("attach", "Attach a domain to an application") diff --git a/rust/src/idn.rs b/rust/src/idn.rs index b48d040c..eeec09b4 100644 --- a/rust/src/idn.rs +++ b/rust/src/idn.rs @@ -6,6 +6,8 @@ use serde_json::Value; +use cli_engine::DetailedError; + use crate::error::GddyError; /// Prefix of an ASCII-compatible-encoded (punycode) label, any case. @@ -27,6 +29,16 @@ pub(crate) fn to_ascii(name: &str) -> Result { }) } +/// Clap value-parser for a domain name, record name or hostname argument: +/// sends the ASCII (lowercase, punycode) form. The error keeps the `fix`, since +/// clap shows only a message. +pub(crate) fn parse_name_arg(raw: &str) -> Result { + to_ascii(raw).map_err(|e| match e.error_fix() { + Some(fix) => format!("{e}. {fix}"), + None => e.to_string(), + }) +} + /// The Unicode form of `name`. Labels that are not valid punycode are left as /// they are, so display never fails. pub(crate) fn to_unicode(name: &str) -> String { @@ -158,6 +170,20 @@ mod tests { assert!(to_ascii("\u{301}abc.com").is_err()); } + #[test] + fn parse_name_arg_sends_ascii_and_keeps_the_fix_in_its_error() { + assert_eq!( + parse_name_arg("MÜNCHEN.de").expect("valid name"), + "xn--mnchen-3ya.de" + ); + let err = parse_name_arg("\u{301}abc.com").expect_err("invalid name"); + assert!( + err.contains("is not a valid internationalized name"), + "{err}" + ); + assert!(err.contains("Remove any characters"), "{err}"); + } + #[test] fn to_unicode_decodes_punycode_labels() { assert_eq!(to_unicode("xn--mnchen-3ya.de"), "münchen.de");