diff --git a/crates/irtt-app/src/cmd/client/args.rs b/crates/irtt-app/src/cmd/client/args.rs index f2e542a..c3e8ad9 100644 --- a/crates/irtt-app/src/cmd/client/args.rs +++ b/crates/irtt-app/src/cmd/client/args.rs @@ -11,12 +11,12 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Parser)] #[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")] pub struct ClientArgs { - /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode. + /// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. #[arg( value_name = "TARGET", num_args = 0.., value_parser = parse_target, - long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client host-a:2112 eu=host-b:2112" + long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target." )] pub targets: Vec, @@ -159,8 +159,7 @@ mod tests { let args = parse(&["127.0.0.1:2112"]).unwrap(); assert_eq!(args.targets.len(), 1); - assert_eq!(args.targets[0].label, None); - assert_eq!(args.targets[0].addr, "127.0.0.1:2112"); + assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112"); assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.duration, DEFAULT_CLIENT_DURATION); assert_eq!(args.interval, Duration::from_secs(1)); @@ -254,8 +253,8 @@ mod tests { #[test] fn invalid_labelled_target_syntax_is_rejected() { - assert!(parse(&["=127.0.0.1:2112"]).is_err()); - assert!(parse(&["label="]).is_err()); + assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err()); + assert!(parse(&["label="]).unwrap().prepare().is_err()); } #[test] diff --git a/crates/irtt-app/src/cmd/tui/args.rs b/crates/irtt-app/src/cmd/tui/args.rs index 8e1320c..9c32abd 100644 --- a/crates/irtt-app/src/cmd/tui/args.rs +++ b/crates/irtt-app/src/cmd/tui/args.rs @@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO; #[derive(Debug, Clone, Parser)] #[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] pub struct TuiArgs { - /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode. + /// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. #[arg( value_name = "TARGET", num_args = 1.., required = true, value_parser = parse_target, - long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example" + long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example" )] pub targets: Vec, @@ -107,8 +107,7 @@ mod tests { fn tui_parser_defaults_to_continuous_and_has_no_output_option() { let args = parse(&["127.0.0.1:2112"]).unwrap(); assert_eq!(args.targets.len(), 1); - assert_eq!(args.targets[0].label, None); - assert_eq!(args.targets[0].addr, "127.0.0.1:2112"); + assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112"); assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.duration, DEFAULT_TUI_DURATION); assert!(args.is_continuous()); @@ -181,8 +180,8 @@ mod tests { #[test] fn invalid_labelled_target_syntax_is_rejected() { - assert!(parse(&["=127.0.0.1:2112"]).is_err()); - assert!(parse(&["label="]).is_err()); + assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err()); + assert!(parse(&["label="]).unwrap().prepare().is_err()); } #[test] diff --git a/crates/irtt-app/src/shared/client/prepare.rs b/crates/irtt-app/src/shared/client/prepare.rs index a91d026..2540af1 100644 --- a/crates/irtt-app/src/shared/client/prepare.rs +++ b/crates/irtt-app/src/shared/client/prepare.rs @@ -119,17 +119,11 @@ mod tests { } fn unlabeled(addr: &str) -> TargetArg { - TargetArg { - label: None, - addr: addr.to_owned(), - } + TargetArg::new(addr) } fn labeled(label: &str, addr: &str) -> TargetArg { - TargetArg { - label: Some(label.to_owned()), - addr: addr.to_owned(), - } + TargetArg::new(format!("{label}={addr}")) } fn prepare( diff --git a/crates/irtt-app/src/shared/client/targets.rs b/crates/irtt-app/src/shared/client/targets.rs index 35c2d09..392c4d3 100644 --- a/crates/irtt-app/src/shared/client/targets.rs +++ b/crates/irtt-app/src/shared/client/targets.rs @@ -1,26 +1,99 @@ -use std::collections::HashSet; +use std::{collections::HashSet, fmt}; use clap::ValueEnum; -use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId}; +use irtt_client::{ + managed::{ManagedPacing, ManagedTargetConfig, TargetId}, + ClientAuthConfig, +}; -#[derive(Debug, Clone, PartialEq, Eq)] +/// One raw positional target captured by Clap. +/// +/// Parsing happens during run preparation so invalid target syntax never makes +/// Clap include the original argument (which may carry an HMAC key) in an +/// error diagnostic. +#[derive(Clone, PartialEq, Eq)] pub struct TargetArg { - pub label: Option, - pub addr: String, + input: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl TargetArg { + pub fn new(input: impl Into) -> Self { + Self { + input: input.into(), + } + } +} + +impl fmt::Debug for TargetArg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TargetArg()") + } +} + +/// Capture one positional target without exposing it through Clap diagnostics. +pub fn parse_target(input: &str) -> Result { + Ok(TargetArg::new(input)) +} + +#[derive(Clone, PartialEq, Eq)] +pub enum TargetAuth { + Inherit, + Override(Vec), + Disable, +} + +impl fmt::Debug for TargetAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Inherit => f.write_str("Inherit"), + Self::Override(_) => f.write_str("Override()"), + Self::Disable => f.write_str("Disable"), + } + } +} + +#[derive(Clone, PartialEq, Eq)] pub struct TargetSpec { pub label: String, pub addr: String, + pub auth: TargetAuth, } -#[derive(Debug, Clone)] +impl fmt::Debug for TargetSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TargetSpec") + .field("label", &self.label) + .field("addr", &self.addr) + .field("auth", &self.auth) + .finish() + } +} + +#[derive(Clone)] pub struct PreparedTarget { pub label: String, pub managed: ManagedTargetConfig, } +impl fmt::Debug for PreparedTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PreparedTarget") + .field("label", &self.label) + .field("server_addr", &self.managed.server_addr) + .field( + "auth", + &match &self.managed.auth { + None => TargetAuth::Inherit, + Some(ClientAuthConfig { hmac_key: Some(_) }) => { + TargetAuth::Override(Vec::new()) + } + Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable, + }, + ) + .finish() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum GroupPacingArg { Staggered, @@ -36,191 +109,383 @@ impl From for ManagedPacing { } } -/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`. -pub fn parse_target(input: &str) -> Result { - match input.split_once('=') { - None => Ok(TargetArg { - label: None, - addr: input.to_owned(), - }), - Some((label, addr)) => { - if label.is_empty() { - return Err("target label must not be empty".to_owned()); - } - if addr.is_empty() { - return Err("target address must not be empty".to_owned()); - } - Ok(TargetArg { - label: Some(label.to_owned()), - addr: addr.to_owned(), - }) - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetParseError { + EmptyLabel, + EmptyAddress, + InvalidEscape, + EmptyTarget, +} + +impl fmt::Display for TargetParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptyLabel => "target label must not be empty", + Self::EmptyAddress => "target address must not be empty", + Self::InvalidEscape => "invalid target escape sequence", + Self::EmptyTarget => "target must not be empty", + }; + f.write_str(message) } } -pub fn target_specs(targets: &[TargetArg]) -> Result, String> { +fn split_unescaped(input: &str, delimiter: char) -> Result, TargetParseError> { + let mut parts = Vec::new(); + let mut start = 0; + let mut escaped = false; + for (index, ch) in input.char_indices() { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == delimiter { + parts.push(&input[start..index]); + start = index + ch.len_utf8(); + } + } + if escaped { + return Err(TargetParseError::InvalidEscape); + } + parts.push(&input[start..]); + Ok(parts) +} + +fn first_unescaped(input: &str, delimiter: char) -> Result, TargetParseError> { + let mut escaped = false; + for (index, ch) in input.char_indices() { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == delimiter { + return Ok(Some(index)); + } + } + if escaped { + return Err(TargetParseError::InvalidEscape); + } + Ok(None) +} + +fn unescape(input: &str) -> Result { + let mut output = String::with_capacity(input.len()); + let mut escaped = false; + for ch in input.chars() { + if escaped { + if !matches!(ch, '\\' | '=' | ';' | ',' | '@') { + return Err(TargetParseError::InvalidEscape); + } + output.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else { + output.push(ch); + } + } + if escaped { + return Err(TargetParseError::InvalidEscape); + } + Ok(output) +} + +fn hmac_modifier(input: &str) -> Result, TargetParseError> { + let mut escaped = false; + for (index, ch) in input.char_indices() { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if input[index..].starts_with("@hmac=") { + return Ok(Some(index)); + } + } + if escaped { + return Err(TargetParseError::InvalidEscape); + } + Ok(None) +} + +fn parse_target_syntax( + input: &str, +) -> Result<(Option, String, TargetAuth), TargetParseError> { + if input.is_empty() { + return Err(TargetParseError::EmptyTarget); + } + let (main, auth) = match hmac_modifier(input)? { + Some(index) => { + let value = unescape(&input[index + "@hmac=".len()..])?; + let auth = if value.is_empty() { + TargetAuth::Disable + } else { + TargetAuth::Override(value.into_bytes()) + }; + (&input[..index], auth) + } + None => (input, TargetAuth::Inherit), + }; + let (label, address) = match first_unescaped(main, '=')? { + None => (None, unescape(main)?), + Some(index) => { + let label = unescape(&main[..index])?; + let address = unescape(&main[index + '='.len_utf8()..])?; + (Some(label), address) + } + }; + if label.as_deref().is_some_and(str::is_empty) { + return Err(TargetParseError::EmptyLabel); + } + if address.is_empty() { + return Err(TargetParseError::EmptyAddress); + } + Ok((label, address, auth)) +} + +pub(crate) fn target_specs_with_empty( + targets: &[TargetArg], + allow_empty: bool, +) -> Result, String> { let mut specs = Vec::with_capacity(targets.len()); - let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new(); - for target in targets { - let label = match &target.label { - Some(label) => label.clone(), + let mut unlabeled_counts = std::collections::HashMap::::new(); + for (index, target) in targets.iter().enumerate() { + let (explicit_label, addr, auth) = parse_target_syntax(&target.input) + .map_err(|error| format!("invalid target {}: {error}", index + 1))?; + let label = match explicit_label { + Some(label) => label, None => { - let count = unlabeled_counts.entry(target.addr.as_str()).or_default(); + let count = unlabeled_counts.entry(addr.clone()).or_default(); *count += 1; if *count == 1 { - target.addr.clone() + addr.clone() } else { - format!("{}#{}", target.addr, *count) + format!("{}#{}", addr, *count) } } }; - specs.push(TargetSpec { - label, - addr: target.addr.clone(), - }); + specs.push(TargetSpec { label, addr, auth }); } - if specs.is_empty() { + if specs.is_empty() && !allow_empty { return Err("at least one target is required unless --list-columns is set".to_owned()); } let mut labels = HashSet::new(); for spec in &specs { if !labels.insert(spec.label.clone()) { - return Err(format!("duplicate target label {:?}", spec.label)); + return Err("duplicate target label".to_owned()); } } Ok(specs) } +pub fn target_specs(targets: &[TargetArg]) -> Result, String> { + target_specs_with_empty(targets, false) +} + pub fn prepare_managed_targets(specs: Vec) -> Result, String> { let mut targets = Vec::with_capacity(specs.len()); for spec in specs { + let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr); + managed.auth = match spec.auth { + TargetAuth::Inherit => None, + TargetAuth::Override(hmac_key) => Some(ClientAuthConfig { + hmac_key: Some(hmac_key), + }), + TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }), + }; targets.push(PreparedTarget { - label: spec.label.clone(), - managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr), + label: spec.label, + managed, }); } Ok(targets) } +/// Parse one complete stdin target set after its line terminator was removed. +/// +/// Commas frame stdin elements only; positional target arguments do not use +/// this framing and may therefore contain raw commas. +pub fn parse_stdin_target_set( + record: &str, + maximum_targets: usize, +) -> Result, String> { + if record == "[]" { + return Ok(Vec::new()); + } + let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?; + if elements.len() > maximum_targets { + return Err(format!( + "target set exceeds the {maximum_targets}-target limit" + )); + } + let args = elements.into_iter().map(TargetArg::new).collect::>(); + prepare_managed_targets(target_specs_with_empty(&args, true)?) +} + #[cfg(test)] mod tests { use super::*; - fn unlabeled(addr: &str) -> TargetArg { - TargetArg { - label: None, - addr: addr.to_owned(), - } + fn target(input: &str) -> TargetArg { + TargetArg::new(input) } - fn labeled(label: &str, addr: &str) -> TargetArg { - TargetArg { - label: Some(label.to_owned()), - addr: addr.to_owned(), - } + fn specs(inputs: &[&str]) -> Result, String> { + target_specs(&inputs.iter().map(|input| target(input)).collect::>()) } #[test] - fn parse_target_without_equals_is_unlabeled() { - let target = parse_target("host.example").unwrap(); - assert_eq!(target.label, None); - assert_eq!(target.addr, "host.example"); - - let target = parse_target("host.example:2112").unwrap(); - assert_eq!(target.label, None); - assert_eq!(target.addr, "host.example:2112"); - - let target = parse_target("[::1]:2112").unwrap(); - assert_eq!(target.label, None); - assert_eq!(target.addr, "[::1]:2112"); + fn legacy_target_syntax_and_ipv6_remain_supported() { + let parsed = specs(&[ + "host.example", + "host.example:2112", + "[::1]:2112", + "eu=host.example", + ]) + .unwrap(); + assert_eq!(parsed[0].label, "host.example"); + assert_eq!(parsed[1].label, "host.example:2112"); + assert_eq!(parsed[2].label, "[::1]:2112"); + assert_eq!(parsed[3].label, "eu"); + assert!(parsed + .iter() + .all(|target| matches!(target.auth, TargetAuth::Inherit))); } #[test] - fn parse_target_with_equals_splits_on_first() { - let target = parse_target("eu=host.example").unwrap(); - assert_eq!(target.label, Some("eu".to_owned())); - assert_eq!(target.addr, "host.example"); - - let target = parse_target("eu=host.example:2112").unwrap(); - assert_eq!(target.label, Some("eu".to_owned())); - assert_eq!(target.addr, "host.example:2112"); + fn target_hmac_supports_inherit_override_disable_and_base64_padding() { + let parsed = specs(&[ + "default=default.example", + "private=private.example@hmac=YWJjZA==", + "public=public.example@hmac=", + ]) + .unwrap(); + assert!(matches!(parsed[0].auth, TargetAuth::Inherit)); + assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec())); + assert!(matches!(parsed[2].auth, TargetAuth::Disable)); } #[test] - fn parse_target_rejects_empty_label_or_address() { - assert!(parse_target("=host.example").is_err()); - assert!(parse_target("eu=").is_err()); + fn target_delimiters_are_escaped_contextually() { + let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap(); + assert_eq!(parsed[0].label, "eu=west"); + assert_eq!(parsed[0].addr, "host@hmac=literal"); + assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec())); + } + + #[test] + fn positional_commas_are_not_reserved() { + let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap(); + assert_eq!(parsed[0].addr, "host,region"); + assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec())); + } + + #[test] + fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() { + let secret = "very-secret-value"; + let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err(); + assert!(!error.contains(secret)); + assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret)); + assert!( + !format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret) + ); } #[test] fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() { - let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")]; - let specs = target_specs(&targets).unwrap(); - - assert_eq!(specs[0].label, "host-a:2112"); - assert_eq!(specs[1].label, "host-a:2112#2"); - - let targets = vec![ - unlabeled("host-a:2112"), - labeled("host-a:2112", "host-b:2112"), - ]; - let err = target_specs(&targets).unwrap_err(); - assert!(err.contains("duplicate target label")); + let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap(); + assert_eq!(parsed[0].label, "host-a:2112"); + assert_eq!(parsed[1].label, "host-a:2112#2"); + assert_eq!( + specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(), + "duplicate target label" + ); } #[test] - fn target_specs_preserve_argument_order_with_mixed_labels() { - let targets = vec![ - unlabeled("local"), - labeled("eu", "eu.example"), - unlabeled("backup"), - labeled("us", "us.example"), - ]; - let specs = target_specs(&targets).unwrap(); - - let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect(); - assert_eq!(labels, ["local", "eu", "backup", "us"]); - let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect(); - assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]); - } - - #[test] - fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() { - let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")]; - let specs = target_specs(&targets).unwrap(); - - let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect(); - assert_eq!(labels, ["foo", "eu", "foo#2"]); + fn target_specs_preserve_argument_order_and_unlabeled_counts() { + let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap(); + let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect(); + assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]); + let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect(); + assert_eq!( + addresses, + ["local", "eu.example", "foo", "us.example", "foo"] + ); } #[test] fn target_specs_reject_generated_suffix_collisions() { - let targets = vec![ - unlabeled("host.example"), - unlabeled("host.example"), - labeled("host.example#2", "other.example"), - ]; - let err = target_specs(&targets).unwrap_err(); - assert!(err.contains("duplicate target label")); + assert_eq!( + specs(&[ + "host.example", + "host.example", + "host.example#2=other.example" + ]) + .unwrap_err(), + "duplicate target label" + ); } #[test] - fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() { - let specs = vec![ - TargetSpec { - label: "a".to_owned(), - addr: "example.test".to_owned(), - }, - TargetSpec { - label: "b".to_owned(), - addr: "example.test".to_owned(), - }, - ]; - let targets = prepare_managed_targets(specs).unwrap(); - assert_eq!(targets[0].managed.server_addr, "example.test"); - assert_eq!(targets[1].managed.server_addr, "example.test"); + fn malformed_target_syntax_is_rejected_without_echoing_input() { + for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] { + assert!(specs(&[input]).is_err(), "{input}"); + } + } + + #[test] + fn target_preparation_maps_auth_to_managed_config() { + let prepared = + prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap(); + assert_eq!( + prepared[0] + .managed + .auth + .as_ref() + .unwrap() + .hmac_key + .as_deref(), + Some(b"key".as_slice()) + ); + assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None); + } + + #[test] + fn target_preparation_preserves_duplicate_endpoints() { + let prepared = + prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap(); + assert_eq!(prepared[0].managed.server_addr, "example.test"); + assert_eq!(prepared[1].managed.server_addr, "example.test"); + } + + #[test] + fn stdin_target_sets_frame_commas_without_trimming_payload() { + let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap(); + assert_eq!( + parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(), + Some(b"x,y".as_slice()) + ); + assert_eq!(parsed[1].label, "b"); + assert_eq!( + parse_stdin_target_set(" []", 128).unwrap()[0] + .managed + .server_addr, + " []" + ); + assert_eq!( + parse_stdin_target_set("[] ", 128).unwrap()[0] + .managed + .server_addr, + "[] " + ); + assert!(parse_stdin_target_set("", 128).is_err()); + assert!(parse_stdin_target_set("a=one,b=two", 1).is_err()); + } + + #[test] + fn stdin_empty_set_is_exactly_bracket_pair() { + assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty()); } } diff --git a/docs/irtt-client.md b/docs/irtt-client.md index 9cd011c..c4bd746 100644 --- a/docs/irtt-client.md +++ b/docs/irtt-client.md @@ -6,7 +6,7 @@ irtt-client - IRTT-compatible stream client ## SYNOPSIS -`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET*]... +`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]... `irtt-client` `--list-columns` @@ -18,17 +18,26 @@ eligible runs. ## TARGETS -Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix -assigns the logical target name used in output; without it, the target -string itself is used as the label. Labeled and unlabeled targets can be -freely mixed in one argument list. +Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional +`LABEL=` prefix assigns the logical target name used in output; without it, +the target string itself is used as the label. Labeled and unlabeled targets +can be freely mixed in one argument list. Prefer explicit labels for +long-running dynamic target sets: the label is the stable target identity +across replacements. ```sh irtt-client host-a:2112 irtt-client eu=host-a:2112 us=host-b:2112 irtt-client host-a:2112 eu=host-b:2112 +irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac= ``` +Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY` +overrides it for that target, and `@hmac=` explicitly disables HMAC for that +target. The modifier uses `@`, not a shell command separator; quote an argument +if its key needs shell quoting. A literal `@` immediately before `hmac=` in +the target portion is written as `\@`. + Multiple targets are probed concurrently. By default, a multi-target session uses **staggered** pacing, spacing active targets across the probe interval; pass `--pacing burst` to send one probe to every active target back-to-back