Pull request 186: TRUST-427 add client random prefix rule generator

Squashed commit of the following:

commit caa2375e43ca3c9b03242eeadca51d6509ee6712
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Thu Mar 19 21:28:25 2026 +0500

    Use rand::rngs::OsRng instead of rand::thread_rng

commit 7d837b0ec7550cb3785ee2e42bb5838f024b2e36
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Thu Mar 19 19:32:46 2026 +0500

    Skip validation for generated prefix

commit bba7486bc02e90239948a587f254f2a883ca2762
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Thu Mar 19 19:27:44 2026 +0500

    Always append new rule at the beginning of the rules array

commit a3d12d12a1ff500b5a0f103063a59159248aa525
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Thu Mar 19 19:17:11 2026 +0500

    Append new rules before catch-all deny

commit e6b119bd9f390af88cdef6f664552f8df65e3569
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 22:09:52 2026 +0500

    Improve code quality

commit 8689e369e7908139e2e125d68f00fc0fe5991d7b
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:56:15 2026 +0500

    Fix code formatting

commit 15ccbdfc65ea4df0cc57bcb9a13351bea27b635e
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:50:00 2026 +0500

    Document generated client random prefix export flow

commit 9f0a0452d92c094329b39922350ea5aa269ba615
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:47:33 2026 +0500

    Add test coverage for endpoint client random prefix generation flow

commit 7993ce619a8c1b7810acd1800bc1414e210d63ef
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:45:29 2026 +0500

    Generate client random prefix in endpoint config export

commit 603572ba8a48b9369dcdd448ecf9f965f03c6828
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:40:47 2026 +0500

    Add endpoint CLI flags for client random prefix generation

commit 4683d7153c6e7f7979163cdb90d8a92564b43691
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:37:25 2026 +0500

    Add test coverage for client random prefix generator

commit 53f80338889ac6e3ee2d916a03f5ea42ce25a1a4
Author: Ilia Zhirov <i.zhirov@adguard.com>
Date:   Wed Mar 18 21:29:20 2026 +0500

    Add shared client random prefix generator
This commit is contained in:
Ilia Zhirov 2026-03-23 09:04:49 +00:00
parent f5574bd065
commit 55e28c2fd5
9 changed files with 562 additions and 76 deletions

View file

@ -1,5 +1,7 @@
# CHANGELOG
- [Feature] `trusttunnel_endpoint -c` can now generate `client_random_prefix` values automatically, append matching allow rules to `rules.toml`, and embed the generated value into exported client configs.
## 1.0.17
- [Fix] Reverse proxy routing for H2/H3.

View file

@ -53,6 +53,11 @@ The endpoint binary accepts the following command line arguments:
| `<tls_hosts_settings>` | - | **Required.** Path to TLS hosts settings file | - |
| `--client_config` | `-c` | Print endpoint config for specified client and exit | - |
| `--address` | `-a` | Endpoint address to add to client config (requires `-c`). Accepts `ip`, `ip:port`, `domain`, or `domain:port`. | - |
| `--client-random-prefix` | `-r` | Use an explicit `client_random_prefix` in the exported client config (requires `-c`). | - |
| `--generate-client-random-prefix` | - | Generate a new `client_random_prefix`, append a matching allow rule to `rules.toml`, and use it in the exported client config (requires `-c`). | - |
| `--prefix-length` | - | Length in bytes for generated `client_random_prefix` values (requires `--generate-client-random-prefix`). | `4` |
| `--prefix-percent` | - | Percentage of one bits in the generated mask (requires `--generate-client-random-prefix`). | `70` |
| `--prefix-mask` | - | Explicit hex mask for generated `client_random_prefix` values (requires `--generate-client-random-prefix`). Conflicts with `--prefix-length` and `--prefix-percent`. | - |
### Examples
@ -77,6 +82,18 @@ The endpoint binary accepts the following command line arguments:
# Export client configuration with domain name and explicit port
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com:443
# Export client configuration with an explicit client_random_prefix
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com \
--client-random-prefix a0b0/f0f0
# Generate a new client_random_prefix, append an allow rule to rules.toml, and export it
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com \
--generate-client-random-prefix
# Generate a new client_random_prefix with a custom mask
./trusttunnel_endpoint vpn.toml hosts.toml -c username -a vpn.example.com \
--generate-client-random-prefix --prefix-mask ffff0000
```
---

2
Cargo.lock generated
View file

@ -3549,6 +3549,7 @@ dependencies = [
"once_cell",
"prometheus",
"quiche",
"rand 0.8.5",
"ring",
"rustls",
"rustls-native-certs 0.7.3",
@ -3590,6 +3591,7 @@ dependencies = [
"sentry",
"tokio",
"toml",
"toml_edit 0.19.15",
"trusttunnel",
]

View file

@ -261,6 +261,11 @@ cd /opt/trusttunnel/
# Or explicitly specify the format:
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <address> --format deeplink
# Generate a new client_random_prefix, append a matching allow rule to rules.toml,
# and include it in the exported client config:
./trusttunnel_endpoint vpn.toml hosts.toml -c <client_name> -a <address> \
--generate-client-random-prefix
```
This outputs a `tt://?` deep-link URI that can be:
@ -268,6 +273,10 @@ This outputs a `tt://?` deep-link URI that can be:
- Shared directly with mobile clients
- Used with the [CLI client][trusttunnel-client] or [TrustTunnel Flutter Client][trusttunnel-flutter-client]
When `--generate-client-random-prefix` is used, the endpoint also appends an
allow rule for the generated value to the `rules.toml` file referenced from
`vpn.toml`.
**Note**: If your certificate is signed by a trusted CA (e.g., Let's Encrypt), it will be
automatically omitted from the deep-link to keep it compact. Self-signed
certificates are included automatically.

View file

@ -18,6 +18,7 @@ nix = { version = "0.28.0", features = ["resource"] }
sentry = { version = "0.46.0", default-features = false, features = ["backtrace", "panic", "reqwest", "rustls", "contexts"] }
tokio = { version = "1.42", features = ["rt-multi-thread", "signal"] }
toml = { version = "0.7.4", default-features = false, features = ["parse"] }
toml_edit = "0.19"
trusttunnel = { version = "0.1", path = "../lib" }
[features]

View file

@ -1,11 +1,14 @@
use log::{debug, error, info, warn, LevelFilter};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use tokio::signal;
use toml_edit::{Document, Item, Table};
use trusttunnel::authentication::registry_based::RegistryBasedAuthenticator;
use trusttunnel::authentication::Authenticator;
use trusttunnel::client_config;
use trusttunnel::client_random_prefix::{self, GeneratorParams};
use trusttunnel::core::Core;
use trusttunnel::settings::Settings;
use trusttunnel::shutdown::Shutdown;
@ -21,6 +24,10 @@ const CLIENT_CONFIG_PARAM_NAME: &str = "client_config";
const ADDRESS_PARAM_NAME: &str = "address";
const CUSTOM_SNI_PARAM_NAME: &str = "custom_sni";
const CLIENT_RANDOM_PREFIX_PARAM_NAME: &str = "client_random_prefix";
const GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME: &str = "generate_client_random_prefix";
const PREFIX_LENGTH_PARAM_NAME: &str = "prefix_length";
const PREFIX_PERCENT_PARAM_NAME: &str = "prefix_percent";
const PREFIX_MASK_PARAM_NAME: &str = "prefix_mask";
const FORMAT_PARAM_NAME: &str = "format";
const SENTRY_DSN_PARAM_NAME: &str = "sentry_dsn";
const THREADS_NUM_PARAM_NAME: &str = "threads_num";
@ -129,6 +136,32 @@ fn main() {
.short('r')
.long("client-random-prefix")
.help("TLS client random hex prefix for connection filtering. Must have a corresponding rule in rules.toml."),
clap::Arg::new(GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME)
.action(clap::ArgAction::SetTrue)
.requires(CLIENT_CONFIG_PARAM_NAME)
.long("generate-client-random-prefix")
.conflicts_with(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.help("Generate a new TLS client random prefix for connection filtering and use it in the exported client config."),
clap::Arg::new(PREFIX_LENGTH_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME)
.long("prefix-length")
.value_parser(clap::value_parser!(usize))
.default_value("4")
.help("Generated client random prefix length in bytes."),
clap::Arg::new(PREFIX_PERCENT_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME)
.long("prefix-percent")
.value_parser(clap::value_parser!(u8))
.default_value("70")
.help("Percentage of one bits in the generated client random mask."),
clap::Arg::new(PREFIX_MASK_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME)
.long("prefix-mask")
.conflicts_with_all([PREFIX_LENGTH_PARAM_NAME, PREFIX_PERCENT_PARAM_NAME])
.help("Explicit mask for generated client random prefix in hex format."),
clap::Arg::new(FORMAT_PARAM_NAME)
.action(clap::ArgAction::Set)
.requires(CLIENT_CONFIG_PARAM_NAME)
@ -184,10 +217,10 @@ fn main() {
increase_fd_limit();
let settings_path = args.get_one::<String>(SETTINGS_PARAM_NAME).unwrap();
let settings: Settings = toml::from_str(
&std::fs::read_to_string(settings_path).expect("Couldn't read the settings file"),
)
.expect("Couldn't parse the settings file");
let settings_contents =
std::fs::read_to_string(settings_path).expect("Couldn't read the settings file");
let settings: Settings =
toml::from_str(&settings_contents).expect("Couldn't parse the settings file");
if settings.get_clients().is_empty() && settings.get_listen_address().ip().is_loopback() {
warn!(
@ -242,89 +275,145 @@ fn main() {
}
}
let mut client_random_prefix = args
.get_one::<String>(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.cloned();
if let Some(ref prefix) = client_random_prefix {
let has_slash = prefix.contains('/');
let (input_prefix, input_mask) = prefix.split_once('/').unwrap_or((prefix, ""));
let generated_client_random_prefix = if args
.get_flag(GENERATE_CLIENT_RANDOM_PREFIX_PARAM_NAME)
{
let generated =
if let Some(mask_hex) = args.get_one::<String>(PREFIX_MASK_PARAM_NAME) {
let mask = hex::decode(mask_hex).unwrap_or_else(|_| {
eprintln!("Error: prefix_mask '{}' is not valid hex", mask_hex);
std::process::exit(1);
});
// Validate hex format
if hex::decode(input_prefix).is_err() {
eprintln!("Error: client_random_prefix '{}' is not valid hex", prefix);
std::process::exit(1);
}
if (has_slash && input_mask.is_empty())
|| (!input_mask.is_empty() && hex::decode(input_mask).is_err())
{
eprintln!(
"Error: client_random_prefix mask '{}' is not valid hex",
input_mask
);
std::process::exit(1);
}
// Validate against rules.toml
if let Some(rules_engine) = settings.get_rules_engine() {
let input_mask: Option<&str> = if input_mask.is_empty() {
None
client_random_prefix::generate_with_mask(mask)
} else {
Some(input_mask)
};
let matching_rule = rules_engine.config().rule.iter().find(|rule| {
rule.client_random_prefix
.as_ref()
.map(|p| {
let (rule_prefix, rule_mask): (&str, Option<&str>) = p
.split_once('/')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((p.as_str(), None));
// Prefix parts must be equal
if rule_prefix != input_prefix {
return false;
}
// Mask compatibility: input mask must be same or stronger than rule mask.
// "Stronger" means more bits set, i.e. (input_mask & rule_mask) == rule_mask.
match (input_mask, rule_mask) {
// Rule has no mask, any input mask is at least as strong
(_, None) => true,
// Input has no mask, strongest possible
(None, Some(_)) => true,
// Both have masks, input mask must cover all bits of rule mask
(Some(mi_str), Some(mr_str)) => {
match (hex::decode(mi_str), hex::decode(mr_str)) {
(Ok(mi), Ok(mr)) => {
mi.len() >= mr.len()
&& (0..mr.len()).all(|i| mi[i] & mr[i] == mr[i])
}
_ => false,
}
}
}
})
.unwrap_or(false)
client_random_prefix::generate(GeneratorParams {
length: *args.get_one::<usize>(PREFIX_LENGTH_PARAM_NAME).unwrap(),
percent: *args.get_one::<u8>(PREFIX_PERCENT_PARAM_NAME).unwrap(),
})
}
.unwrap_or_else(|err| {
eprintln!("Error: {}", err);
std::process::exit(1);
});
// Print warning and continue, do not panic because it's optional field
match matching_rule {
None => {
eprintln!(
let generated_prefix = generated.to_masked_hex_string();
let rules_path = extract_rules_file_path(&settings_contents, settings_path).unwrap_or_else(|| {
eprintln!(
"Error: rules_file must be configured in settings to generate client_random_prefix"
);
std::process::exit(1);
});
append_allow_rule(&rules_path, &generated_prefix).unwrap_or_else(|err| {
eprintln!(
"Error: failed to append generated client_random_prefix to '{}': {}",
rules_path.display(),
err
);
std::process::exit(1);
});
eprintln!(
"Added allow rule to '{}': {}",
rules_path.display(),
generated_prefix
);
Some(generated_prefix)
} else {
None
};
let is_generated = generated_client_random_prefix.is_some();
let mut client_random_prefix = generated_client_random_prefix.or_else(|| {
args.get_one::<String>(CLIENT_RANDOM_PREFIX_PARAM_NAME)
.cloned()
});
// Validate explicit --client-random-prefix (skip for generated prefix)
if !is_generated {
if let Some(ref prefix) = client_random_prefix {
let has_slash = prefix.contains('/');
let (input_prefix, input_mask) = prefix.split_once('/').unwrap_or((prefix, ""));
// Validate hex format
if hex::decode(input_prefix).is_err() {
eprintln!("Error: client_random_prefix '{}' is not valid hex", prefix);
std::process::exit(1);
}
if (has_slash && input_mask.is_empty())
|| (!input_mask.is_empty() && hex::decode(input_mask).is_err())
{
eprintln!(
"Error: client_random_prefix mask '{}' is not valid hex",
input_mask
);
std::process::exit(1);
}
// Validate against rules.toml
if let Some(rules_engine) = settings.get_rules_engine() {
let input_mask: Option<&str> = if input_mask.is_empty() {
None
} else {
Some(input_mask)
};
let matching_rule = rules_engine.config().rule.iter().find(|rule| {
rule.client_random_prefix
.as_ref()
.map(|p| {
let (rule_prefix, rule_mask): (&str, Option<&str>) = p
.split_once('/')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((p.as_str(), None));
// Prefix parts must be equal
if rule_prefix != input_prefix {
return false;
}
// Mask compatibility: input mask must be same or stronger than rule mask.
// "Stronger" means more bits set, i.e. (input_mask & rule_mask) == rule_mask.
match (input_mask, rule_mask) {
// Rule has no mask, any input mask is at least as strong
(_, None) => true,
// Input has no mask, strongest possible
(None, Some(_)) => true,
// Both have masks, input mask must cover all bits of rule mask
(Some(mi_str), Some(mr_str)) => {
match (hex::decode(mi_str), hex::decode(mr_str)) {
(Ok(mi), Ok(mr)) => {
mi.len() >= mr.len()
&& (0..mr.len()).all(|i| mi[i] & mr[i] == mr[i])
}
_ => false,
}
}
}
})
.unwrap_or(false)
});
// Print warning and continue, do not panic because it's optional field
match matching_rule {
None => {
eprintln!(
"Warning: No rule found in rules.toml matching client_random_prefix '{}'. This field will be ignored.",
prefix
);
client_random_prefix = None;
}
Some(rule) if rule.action == trusttunnel::rules::RuleAction::Deny => {
eprintln!(
client_random_prefix = None;
}
Some(rule) if rule.action == trusttunnel::rules::RuleAction::Deny => {
eprintln!(
"Warning: Matched rule in rules.toml for client_random_prefix '{}' has action 'deny'.",
prefix
);
}
Some(_) => {}
}
Some(_) => {}
}
}
}
@ -483,6 +572,52 @@ fn domain_matches_tls_hosts(domain: &str, tls_hosts_settings: &settings::TlsHost
.any(|h| h.hostname == domain || h.allowed_sni.iter().any(|s| s == domain))
}
fn append_allow_rule(rules_path: &Path, client_random_prefix: &str) -> std::io::Result<()> {
let content = std::fs::read_to_string(rules_path).unwrap_or_default();
let mut doc: Document = content
.parse()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut new_rule = Table::new();
new_rule.insert(
"client_random_prefix",
toml_edit::value(client_random_prefix),
);
new_rule.insert("action", toml_edit::value("allow"));
let rules = doc
.entry("rule")
.or_insert(Item::ArrayOfTables(Default::default()))
.as_array_of_tables_mut()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid rules format")
})?;
let tail: Vec<Table> = rules.iter().cloned().collect();
rules.clear();
rules.push(new_rule);
for table in tail {
rules.push(table);
}
std::fs::write(rules_path, doc.to_string())
}
fn extract_rules_file_path(settings_contents: &str, settings_path: &str) -> Option<PathBuf> {
let value = settings_contents.parse::<toml::Value>().ok()?;
let rules_file = value.get("rules_file")?.as_str()?;
let path = Path::new(rules_file);
if path.is_absolute() {
return Some(path.to_path_buf());
}
let settings_dir = Path::new(settings_path)
.parent()
.unwrap_or_else(|| Path::new("."));
Some(settings_dir.join(path))
}
/// Parse an endpoint address string into a normalized `host:port` format.
///
/// Accepts the following formats:
@ -667,4 +802,111 @@ mod tests {
"[2001:db8::1]:443"
);
}
#[test]
fn test_extract_rules_file_path_resolves_relative_to_settings_file() {
let settings_dir = std::env::temp_dir().join("trusttunnel_settings_dir_relative");
std::fs::create_dir_all(&settings_dir).unwrap();
let settings_path = settings_dir.join("vpn.toml");
let settings_contents = r#"rules_file = "rules.toml""#;
let rules_path =
extract_rules_file_path(settings_contents, settings_path.to_str().unwrap()).unwrap();
assert_eq!(rules_path, settings_dir.join("rules.toml"));
}
#[test]
fn test_extract_rules_file_path_keeps_absolute_path() {
let absolute_rules = std::env::temp_dir().join("trusttunnel_absolute_rules.toml");
let settings_path = std::env::temp_dir().join("trusttunnel_settings.toml");
let settings_contents = format!("rules_file = \"{}\"", absolute_rules.display());
let rules_path =
extract_rules_file_path(&settings_contents, settings_path.to_str().unwrap()).unwrap();
assert_eq!(rules_path, absolute_rules);
}
#[test]
fn test_extract_rules_file_path_returns_none_without_rules_file() {
assert!(
extract_rules_file_path("listen_address = \"127.0.0.1:443\"", "vpn.toml").is_none()
);
}
#[test]
fn test_append_allow_rule_creates_new_rule() {
let rules_path = std::env::temp_dir().join("trusttunnel_append_allow_rule_create.toml");
let _ = std::fs::remove_file(&rules_path);
append_allow_rule(&rules_path, "abcd/fff0").unwrap();
let contents = std::fs::read_to_string(&rules_path).unwrap();
assert!(contents.contains("[[rule]]"));
assert!(contents.contains("client_random_prefix = \"abcd/fff0\""));
assert!(contents.contains("action = \"allow\""));
let _ = std::fs::remove_file(&rules_path);
}
#[test]
fn test_append_allow_rule_appends_after_existing_allow() {
let rules_path = std::env::temp_dir().join("trusttunnel_append_allow_rule_append.toml");
std::fs::write(
&rules_path,
"[[rule]]\ncidr = \"10.0.0.0/8\"\naction = \"allow\"\n",
)
.unwrap();
append_allow_rule(&rules_path, "1234/ff00").unwrap();
let contents = std::fs::read_to_string(&rules_path).unwrap();
assert!(contents.contains("cidr = \"10.0.0.0/8\""));
assert!(contents.contains("client_random_prefix = \"1234/ff00\""));
let _ = std::fs::remove_file(&rules_path);
}
#[test]
fn test_append_allow_rule_inserts_before_catchall_deny() {
let rules_path =
std::env::temp_dir().join("trusttunnel_append_allow_rule_before_catchall.toml");
std::fs::write(&rules_path, "[[rule]]\naction = \"deny\"\n").unwrap();
append_allow_rule(&rules_path, "abcd/fff0").unwrap();
let contents = std::fs::read_to_string(&rules_path).unwrap();
let allow_pos = contents.find("client_random_prefix").unwrap();
let deny_pos = contents.find("action = \"deny\"").unwrap();
assert!(
allow_pos < deny_pos,
"allow rule should appear before catch-all deny"
);
let _ = std::fs::remove_file(&rules_path);
}
#[test]
fn test_append_allow_rule_inserts_before_specific_deny() {
let rules_path =
std::env::temp_dir().join("trusttunnel_append_allow_rule_specific_deny.toml");
std::fs::write(
&rules_path,
"[[rule]]\ncidr = \"192.168.0.0/16\"\naction = \"deny\"\n",
)
.unwrap();
append_allow_rule(&rules_path, "abcd/fff0").unwrap();
let contents = std::fs::read_to_string(&rules_path).unwrap();
let allow_pos = contents.find("client_random_prefix").unwrap();
let deny_pos = contents.find("action = \"deny\"").unwrap();
assert!(
allow_pos < deny_pos,
"allow rule should appear before any existing rule"
);
let _ = std::fs::remove_file(&rules_path);
}
}

View file

@ -32,6 +32,7 @@ macros = { version = "0.1.0", path = "../macros", optional = true }
once_cell = "1.18.0"
prometheus = { version = "0.14", features = ["process"] }
quiche = { version = "0.24.5", features = ["qlog", "boringssl-boring-crate"] }
rand = "0.8"
ring = "0.17.12"
rustls = { version = "0.23.37", features = ["logging"] }
rustls-native-certs = "0.7"

View file

@ -0,0 +1,211 @@
use rand::rngs::OsRng;
use rand::Rng;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
EmptyMask,
InvalidLength(usize),
InvalidPercent(u8),
MaskTooLong(usize),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyMask => write!(f, "mask must not be empty"),
Self::InvalidLength(length) => {
write!(f, "length must be in range 1..=32 bytes, got {}", length)
}
Self::InvalidPercent(percent) => {
write!(f, "percent must be in range 1..=100, got {}", percent)
}
Self::MaskTooLong(length) => {
write!(
f,
"mask length must be in range 1..=32 bytes, got {}",
length
)
}
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GeneratorParams {
pub length: usize,
pub percent: u8,
}
impl Default for GeneratorParams {
fn default() -> Self {
Self {
length: 4,
percent: 70,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedPrefix {
value: Vec<u8>,
mask: Vec<u8>,
}
impl GeneratedPrefix {
fn new(value: Vec<u8>, mask: Vec<u8>) -> Self {
Self { value, mask }
}
pub fn value(&self) -> &[u8] {
&self.value
}
pub fn mask(&self) -> &[u8] {
&self.mask
}
pub fn to_masked_hex_string(&self) -> String {
format!("{}/{}", hex::encode(&self.value), hex::encode(&self.mask))
}
}
pub fn generate(params: GeneratorParams) -> Result<GeneratedPrefix, Error> {
validate_params(params)?;
let mask = generate_mask(params.length, params.percent);
let value = generate_value(&mask);
Ok(GeneratedPrefix::new(value, mask))
}
pub fn generate_with_mask(mask: Vec<u8>) -> Result<GeneratedPrefix, Error> {
validate_mask(&mask)?;
let value = generate_value(&mask);
Ok(GeneratedPrefix::new(value, mask))
}
fn validate_params(params: GeneratorParams) -> Result<(), Error> {
if !(1..=32).contains(&params.length) {
return Err(Error::InvalidLength(params.length));
}
if !(1..=100).contains(&params.percent) {
return Err(Error::InvalidPercent(params.percent));
}
Ok(())
}
fn validate_mask(mask: &[u8]) -> Result<(), Error> {
if mask.is_empty() {
return Err(Error::EmptyMask);
}
if mask.len() > 32 {
return Err(Error::MaskTooLong(mask.len()));
}
Ok(())
}
fn generate_mask(length: usize, percent: u8) -> Vec<u8> {
let mut mask = vec![0u8; length];
let mut rng = rand::thread_rng();
for byte in &mut mask {
for bit in 0..8 {
if rng.gen_range(0..100) < percent {
*byte |= 1 << bit;
}
}
}
mask
}
fn generate_value(mask: &[u8]) -> Vec<u8> {
let mut value = vec![0u8; mask.len()];
for (byte, mask_byte) in value.iter_mut().zip(mask.iter().copied()) {
*byte = OsRng.gen::<u8>() & mask_byte;
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_uses_default_length() {
let generated = generate(GeneratorParams::default()).unwrap();
assert_eq!(generated.value().len(), 4);
assert_eq!(generated.mask().len(), 4);
}
#[test]
fn generate_rejects_invalid_length() {
let err = generate(GeneratorParams {
length: 0,
percent: 70,
})
.unwrap_err();
assert_eq!(err, Error::InvalidLength(0));
}
#[test]
fn generate_rejects_invalid_percent() {
let err = generate(GeneratorParams {
length: 4,
percent: 0,
})
.unwrap_err();
assert_eq!(err, Error::InvalidPercent(0));
}
#[test]
fn generated_value_respects_mask_bits() {
let generated = generate(GeneratorParams {
length: 16,
percent: 50,
})
.unwrap();
for (value, mask) in generated.value().iter().zip(generated.mask().iter()) {
assert_eq!(value & !mask, 0);
}
}
#[test]
fn generate_with_mask_preserves_explicit_mask() {
let mask = vec![0xff, 0x00, 0xf0, 0x0f];
let generated = generate_with_mask(mask.clone()).unwrap();
assert_eq!(generated.mask(), mask);
assert_eq!(generated.value()[1], 0);
}
#[test]
fn generate_with_mask_rejects_empty_mask() {
let err = generate_with_mask(Vec::new()).unwrap_err();
assert_eq!(err, Error::EmptyMask);
}
#[test]
fn generated_prefix_formats_as_value_and_mask() {
let generated = generate_with_mask(vec![0xff, 0xf0]).unwrap();
let hex_str = generated.to_masked_hex_string();
assert!(hex_str.ends_with("/fff0"));
assert_eq!(hex_str.len(), "abcd/fff0".len());
}
}

View file

@ -6,6 +6,7 @@ extern crate macros;
pub mod authentication;
pub mod cert_verification;
pub mod client_config;
pub mod client_random_prefix;
pub mod core;
pub mod log_utils;
pub mod net_utils;