diff --git a/Cargo.lock b/Cargo.lock index 38bd387f0..80c2bb163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,9 +554,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" dependencies = [ "clap", ] @@ -752,11 +752,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3287,6 +3286,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", + "tokio", ] [[package]] @@ -3298,9 +3298,9 @@ dependencies = [ [[package]] name = "vey-mimalloc-sys" -version = "0.2.5+3.4.3" +version = "0.2.7+3.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cca3e04ec8a0a5e48a31bcac62a1f318aaf06c9760b98aeb6c91af9748d0d97" +checksum = "8ba7fe4eb17bbbc6543dde51507e6fb675e45beb77bde4ed6651b98692aebc5f" dependencies = [ "bindgen", "cmake", @@ -3508,6 +3508,7 @@ version = "0.3.0" dependencies = [ "anyhow", "redis", + "rustls", "rustls-pki-types", "tokio", "tokio-rustls", diff --git a/lib/vey-build-env/src/basic.rs b/lib/vey-build-env/src/basic.rs index 3a63f3b13..40b47d7c1 100644 --- a/lib/vey-build-env/src/basic.rs +++ b/lib/vey-build-env/src/basic.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::env; @@ -41,3 +42,23 @@ pub fn check_basic() { println!("cargo:rustc-env=VEY_PACKAGE_VERSION={v}"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rustc_version_meta_is_populated() { + let meta = rustc_version::version_meta().unwrap(); + assert!(!meta.short_version_string.is_empty()); + } + + #[test] + fn check_basic_requires_cargo_build_env() { + // check_basic() reads HOST/TARGET/PROFILE/etc. set by Cargo during build scripts. + if std::env::var("HOST").is_err() { + return; + } + check_basic(); + } +} diff --git a/lib/vey-build-env/src/openssl.rs b/lib/vey-build-env/src/openssl.rs index b206134f3..72ecd92e9 100644 --- a/lib/vey-build-env/src/openssl.rs +++ b/lib/vey-build-env/src/openssl.rs @@ -6,18 +6,38 @@ use std::env; -pub fn check_openssl() { +/// Resolve the OpenSSL variant from build-time dependency flags. +pub fn openssl_variant() -> &'static str { if env::var("DEP_OPENSSL_LIBRESSL").is_ok() { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=LibreSSL"); + "LibreSSL" } else if env::var("DEP_OPENSSL_TONGSUO").is_ok() { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=Tongsuo"); + "Tongsuo" } else if env::var("DEP_OPENSSL_BORINGSSL").is_ok() { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=BoringSSL"); + "BoringSSL" } else if env::var("DEP_OPENSSL_AWSLC_FIPS").is_ok() { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=AWS-LC-FIPS"); + "AWS-LC-FIPS" } else if env::var("DEP_OPENSSL_AWSLC").is_ok() { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=AWS-LC"); + "AWS-LC" } else { - println!("cargo:rustc-env=VEY_OPENSSL_VARIANT=OpenSSL"); + "OpenSSL" + } +} + +pub fn check_openssl() { + println!("cargo:rustc-env=VEY_OPENSSL_VARIANT={}", openssl_variant()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_openssl_smoke() { + check_openssl(); + } + + #[test] + fn openssl_variant_defaults_to_openssl_without_flags() { + assert_eq!(openssl_variant(), "OpenSSL"); } } diff --git a/lib/vey-cert-agent/Cargo.toml b/lib/vey-cert-agent/Cargo.toml index b3a49f511..0441ceeba 100644 --- a/lib/vey-cert-agent/Cargo.toml +++ b/lib/vey-cert-agent/Cargo.toml @@ -18,6 +18,9 @@ vey-socket.workspace = true vey-io-ext.workspace = true vey-yaml = { workspace = true, optional = true } +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "net"] } + [features] default = [] tongsuo = ["openssl/tongsuo"] diff --git a/lib/vey-cert-agent/src/config/mod.rs b/lib/vey-cert-agent/src/config/mod.rs index 91e8bc4be..8a1d08be6 100644 --- a/lib/vey-cert-agent/src/config/mod.rs +++ b/lib/vey-cert-agent/src/config/mod.rs @@ -122,3 +122,43 @@ impl CertAgentConfig { )) } } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::num::NonZeroUsize; + use std::time::Duration; + + use super::CertAgentConfig; + + #[test] + fn default_and_setters() { + let mut config = CertAgentConfig::default(); + assert_eq!(config.cache_request_batch_count.get(), 10); + assert_eq!(config.cache_request_timeout, Duration::from_secs(4)); + assert_eq!(config.cache_vanish_wait, Duration::from_secs(300)); + assert_eq!( + config.query_peer_addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2999) + ); + assert_eq!(config.query_wait_timeout, Duration::from_secs(4)); + assert_eq!(config.protective_cache_ttl, 10); + assert_eq!(config.maximum_cache_ttl, 300); + + config.set_cache_request_batch_count(NonZeroUsize::new(3).unwrap()); + config.set_cache_request_timeout(Duration::from_secs(1)); + config.set_cache_vanish_wait(Duration::from_secs(2)); + config.set_query_peer_addr("10.0.0.1:4000".parse().unwrap()); + config.set_query_wait_timeout(Duration::from_millis(500)); + config.set_protective_cache_ttl(5); + config.set_maximum_cache_ttl(60); + + assert_eq!(config.cache_request_batch_count.get(), 3); + assert_eq!(config.cache_request_timeout, Duration::from_secs(1)); + assert_eq!(config.cache_vanish_wait, Duration::from_secs(2)); + assert_eq!(config.query_peer_addr, "10.0.0.1:4000".parse().unwrap()); + assert_eq!(config.query_wait_timeout, Duration::from_millis(500)); + assert_eq!(config.protective_cache_ttl, 5); + assert_eq!(config.maximum_cache_ttl, 60); + } +} diff --git a/lib/vey-cert-agent/src/config/yaml.rs b/lib/vey-cert-agent/src/config/yaml.rs index 90f488e21..6e944a3fb 100644 --- a/lib/vey-cert-agent/src/config/yaml.rs +++ b/lib/vey-cert-agent/src/config/yaml.rs @@ -145,6 +145,19 @@ mod tests { let config = CertAgentConfig::parse_yaml(&yaml).unwrap(); assert_eq!(config.query_peer_addr, "192.168.0.1:5353".parse().unwrap()); assert_eq!(config.cache_request_batch_count.get(), 10); // Default + + // Alias vanish_after_expire + query_wait_timeout + let yaml = yaml_doc!( + r#" + vanish_after_expire: 1m + query_wait_timeout: 2s + query_peer_addr: "[::1]:2999" + "# + ); + let config = CertAgentConfig::parse_yaml(&yaml).unwrap(); + assert_eq!(config.cache_vanish_wait, Duration::from_secs(60)); + assert_eq!(config.query_wait_timeout, Duration::from_secs(2)); + assert_eq!(config.query_peer_addr, "[::1]:2999".parse().unwrap()); } #[test] diff --git a/lib/vey-cert-agent/src/handle.rs b/lib/vey-cert-agent/src/handle.rs index f063e22c2..8f8dc56ff 100644 --- a/lib/vey-cert-agent/src/handle.rs +++ b/lib/vey-cert-agent/src/handle.rs @@ -58,3 +58,61 @@ impl CertAgentHandle { .and_then(|r| r.inner().cloned()) } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use std::time::Duration; + + use tokio::net::UdpSocket; + + use vey_types::net::{Host, TlsCertUsage, TlsServiceType}; + + use super::super::{CertAgentConfig, Request}; + use crate::test_util; + + #[tokio::test] + async fn fetch_from_mock_udp_generator() { + let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer_addr = peer.local_addr().unwrap(); + let (_, _, pem, der_key) = test_util::self_signed_cert_key(); + let (mimic_cert, _, _, _) = test_util::self_signed_cert_key(); + + tokio::spawn(async move { + let mut buf = [0u8; 16384]; + let (n, from) = peer.recv_from(&mut buf).await.unwrap(); + let req = Request::parse_req(&buf[..n]).unwrap(); + assert_eq!(req.host(), &Host::from_str("fetch.example").unwrap()); + assert!(req.cert().is_some()); + let rsp = req.encode_rsp(&pem, &der_key, 90).unwrap(); + peer.send_to(&rsp, from).await.unwrap(); + }); + + let mut config = CertAgentConfig::default(); + config.set_query_peer_addr(peer_addr); + config.set_cache_request_timeout(Duration::from_secs(2)); + config.set_query_wait_timeout(Duration::from_secs(2)); + let handle = config.spawn_cert_agent().unwrap(); + + let pair = handle + .fetch( + TlsServiceType::Http, + TlsCertUsage::TlsServer, + Host::from_str("fetch.example").unwrap(), + mimic_cert, + ) + .await; + assert!(pair.is_some()); + assert_eq!(pair.unwrap().certs.len(), 1); + + // Cache hit without querying again + let cached = handle + .pre_fetch( + TlsServiceType::Http, + TlsCertUsage::TlsServer, + Host::from_str("fetch.example").unwrap(), + ) + .await; + assert!(cached.is_some()); + } +} diff --git a/lib/vey-cert-agent/src/lib.rs b/lib/vey-cert-agent/src/lib.rs index a5c5d6c5c..4ba9ae1bb 100644 --- a/lib/vey-cert-agent/src/lib.rs +++ b/lib/vey-cert-agent/src/lib.rs @@ -171,3 +171,114 @@ impl FakeCertPair { Ok(()) } } + +#[cfg(test)] +mod test_util { + use openssl::asn1::Asn1Time; + use openssl::bn::BigNum; + use openssl::hash::MessageDigest; + use openssl::nid::Nid; + use openssl::pkey::{PKey, Private}; + use openssl::rsa::Rsa; + use openssl::x509::{X509, X509NameBuilder}; + + /// Self-signed RSA leaf used by unit tests. + pub(crate) fn self_signed_cert_key() -> (X509, PKey, String, Vec) { + let rsa = Rsa::generate(2048).unwrap(); + let pkey = PKey::from_rsa(rsa).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_nid(Nid::COMMONNAME, "test.example") + .unwrap(); + let name = name.build(); + + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + let serial = BigNum::from_u32(1).unwrap().to_asn1_integer().unwrap(); + builder.set_serial_number(&serial).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(365).unwrap()) + .unwrap(); + builder.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + + let pem = String::from_utf8(cert.to_pem().unwrap()).unwrap(); + let der_key = pkey.private_key_to_der().unwrap(); + (cert, pkey, pem, der_key) + } +} + +#[cfg(test)] +mod tests { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use std::str::FromStr; + + use openssl::ssl::{Ssl, SslContext, SslMethod}; + + use vey_types::net::{Host, TlsCertUsage, TlsServiceType}; + + use super::{CacheQueryKey, FakeCertPair, test_util}; + + fn hash_of(key: &CacheQueryKey) -> u64 { + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn cache_query_key_eq_and_hash_ignore_mimic_cert() { + let host = Host::from_str("a.example").unwrap(); + let mut a = CacheQueryKey::new(TlsServiceType::Http, TlsCertUsage::TlsServer, host.clone()); + let b = CacheQueryKey::new(TlsServiceType::Http, TlsCertUsage::TlsServer, host); + assert_eq!(a, b); + assert_eq!(hash_of(&a), hash_of(&b)); + + let (cert, _, _, _) = test_util::self_signed_cert_key(); + a.set_mimic_cert(cert); + assert_eq!(a, b); + assert_eq!(hash_of(&a), hash_of(&b)); + } + + #[test] + fn cache_query_key_encode_with_and_without_mimic() { + let host = Host::from_str("encode.example").unwrap(); + let key = CacheQueryKey::new(TlsServiceType::Http, TlsCertUsage::TlsServer, host.clone()); + let buf = key.encode().unwrap(); + assert!(!buf.is_empty()); + + let mut with_mimic = + CacheQueryKey::new(TlsServiceType::Http, TlsCertUsage::TlsServer, host); + let (cert, _, _, _) = test_util::self_signed_cert_key(); + with_mimic.set_mimic_cert(cert); + let buf_mimic = with_mimic.encode().unwrap(); + assert!(buf_mimic.len() > buf.len()); + } + + #[test] + fn fake_cert_pair_add_to_ssl() { + let (cert, key, _, _) = test_util::self_signed_cert_key(); + let pair = FakeCertPair { + certs: vec![cert], + key, + }; + let ctx = SslContext::builder(SslMethod::tls_server()) + .unwrap() + .build(); + let mut ssl = Ssl::new(&ctx).unwrap(); + pair.add_to_ssl(&mut ssl).unwrap(); + + let empty = FakeCertPair { + certs: Vec::new(), + key: test_util::self_signed_cert_key().1, + }; + let mut ssl = Ssl::new(&ctx).unwrap(); + assert!(empty.add_to_ssl(&mut ssl).is_err()); + } +} diff --git a/lib/vey-cert-agent/src/request.rs b/lib/vey-cert-agent/src/request.rs index 843e25657..3b8a36e6b 100644 --- a/lib/vey-cert-agent/src/request.rs +++ b/lib/vey-cert-agent/src/request.rs @@ -171,3 +171,119 @@ impl Request { Ok(buf) } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use rmpv::ValueRef; + + use vey_types::net::{Host, TlsCertUsage, TlsServiceType}; + + use super::super::{request_key, request_key_id}; + use super::Request; + use crate::test_util; + + fn encode_map(entries: Vec<(ValueRef<'_>, ValueRef<'_>)>) -> Vec { + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &ValueRef::Map(entries)).unwrap(); + buf + } + + #[test] + fn parse_req_host_only_string() { + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &ValueRef::String("only.example".into())).unwrap(); + let req = Request::parse_req(&buf).unwrap(); + assert_eq!(req.host(), &Host::from_str("only.example").unwrap()); + assert_eq!(req.cert_usage(), TlsCertUsage::TlsServer); + assert!(req.cert().is_none()); + } + + #[test] + fn parse_req_string_keys_with_mimic_cert() { + let (cert, _, _, _) = test_util::self_signed_cert_key(); + let der = cert.to_der().unwrap(); + let buf = encode_map(vec![ + ( + ValueRef::String(request_key::HOST.into()), + ValueRef::String("str.example".into()), + ), + ( + ValueRef::String(request_key::SERVICE.into()), + ValueRef::Integer((TlsServiceType::Http as u8).into()), + ), + ( + ValueRef::String(request_key::USAGE.into()), + ValueRef::Integer((TlsCertUsage::TLsServerTongsuo as u8).into()), + ), + ( + ValueRef::String(request_key::CERT.into()), + ValueRef::Binary(&der), + ), + ]); + let req = Request::parse_req(&buf).unwrap(); + assert_eq!(req.host(), &Host::from_str("str.example").unwrap()); + assert_eq!(req.cert_usage(), TlsCertUsage::TLsServerTongsuo); + assert!(req.cert().is_some()); + } + + #[test] + fn parse_req_integer_key_ids() { + let buf = encode_map(vec![ + ( + ValueRef::Integer(request_key_id::HOST.into()), + ValueRef::String("id.example".into()), + ), + ( + ValueRef::Integer(request_key_id::SERVICE.into()), + ValueRef::Integer((TlsServiceType::Smtp as u8).into()), + ), + ( + ValueRef::Integer(request_key_id::USAGE.into()), + ValueRef::Integer((TlsCertUsage::TlsServer as u8).into()), + ), + ]); + let req = Request::parse_req(&buf).unwrap(); + assert_eq!(req.host(), &Host::from_str("id.example").unwrap()); + assert_eq!(req.cert_usage(), TlsCertUsage::TlsServer); + } + + #[test] + fn parse_req_rejects_empty_host_and_bad_input() { + let buf = encode_map(vec![( + ValueRef::Integer(request_key_id::SERVICE.into()), + ValueRef::Integer((TlsServiceType::Http as u8).into()), + )]); + assert!(Request::parse_req(&buf).is_err()); + assert!(Request::parse_req(b"not-msgpack").is_err()); + + let buf = encode_map(vec![( + ValueRef::String("unknown_key".into()), + ValueRef::String("x".into()), + )]); + assert!(Request::parse_req(&buf).is_err()); + } + + #[test] + fn encode_rsp_roundtrips_through_response() { + use crate::response::Response; + + let req = Request { + host: Host::from_str("rsp.example").unwrap(), + service: TlsServiceType::Http, + usage: TlsCertUsage::TlsServer, + cert: None, + }; + let (_, _, pem, der_key) = test_util::self_signed_cert_key(); + let buf = req.encode_rsp(&pem, &der_key, 120).unwrap(); + + let mut data = buf.as_slice(); + let v = rmpv::decode::read_value_ref(&mut data).unwrap(); + let rsp = Response::parse(v, 10).unwrap(); + let (key, pair, ttl) = rsp.into_parts().unwrap(); + assert_eq!(ttl, 120); + assert_eq!(key.index.host, Host::from_str("rsp.example").unwrap()); + assert_eq!(pair.certs.len(), 1); + } +} diff --git a/lib/vey-cert-agent/src/response.rs b/lib/vey-cert-agent/src/response.rs index f1a6919f2..7f79d1722 100644 --- a/lib/vey-cert-agent/src/response.rs +++ b/lib/vey-cert-agent/src/response.rs @@ -135,3 +135,110 @@ impl Response { )) } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use rmpv::ValueRef; + + use vey_types::net::{Host, TlsCertUsage, TlsServiceType}; + + use super::super::{response_key, response_key_id}; + use super::Response; + use crate::test_util; + + fn encode_map(entries: Vec<(ValueRef<'_>, ValueRef<'_>)>) -> Vec { + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &ValueRef::Map(entries)).unwrap(); + buf + } + + #[test] + fn parse_response_string_keys() { + let (_, _, pem, der_key) = test_util::self_signed_cert_key(); + let buf = encode_map(vec![ + ( + ValueRef::String(response_key::HOST.into()), + ValueRef::String("s.example".into()), + ), + ( + ValueRef::String(response_key::SERVICE.into()), + ValueRef::Integer((TlsServiceType::Http as u8).into()), + ), + ( + ValueRef::String(response_key::USAGE.into()), + ValueRef::Integer((TlsCertUsage::TlsServer as u8).into()), + ), + ( + ValueRef::String(response_key::CERT_CHAIN.into()), + ValueRef::String(pem.as_str().into()), + ), + ( + ValueRef::String(response_key::PRIVATE_KEY.into()), + ValueRef::Binary(&der_key), + ), + ( + ValueRef::String(response_key::TTL.into()), + ValueRef::Integer(42u32.into()), + ), + ( + ValueRef::String("ignored".into()), + ValueRef::String("x".into()), + ), + ]); + let mut data = buf.as_slice(); + let v = rmpv::decode::read_value_ref(&mut data).unwrap(); + let rsp = Response::parse(v, 10).unwrap(); + let (key, pair, ttl) = rsp.into_parts().unwrap(); + assert_eq!(ttl, 42); + assert_eq!(key.index.host, Host::from_str("s.example").unwrap()); + assert_eq!(pair.certs.len(), 1); + } + + #[test] + fn parse_response_integer_keys_and_errors() { + let (_, _, pem, der_key) = test_util::self_signed_cert_key(); + let buf = encode_map(vec![ + ( + ValueRef::Integer(response_key_id::HOST.into()), + ValueRef::String("i.example".into()), + ), + ( + ValueRef::Integer(response_key_id::SERVICE.into()), + ValueRef::Integer((TlsServiceType::Http as u8).into()), + ), + ( + ValueRef::Integer(response_key_id::USAGE.into()), + ValueRef::Integer((TlsCertUsage::TlsServer as u8).into()), + ), + ( + ValueRef::Integer(response_key_id::CERT_CHAIN.into()), + ValueRef::String(pem.as_str().into()), + ), + ( + ValueRef::Integer(response_key_id::PRIVATE_KEY.into()), + ValueRef::Binary(&der_key), + ), + ( + ValueRef::Integer(response_key_id::TTL.into()), + ValueRef::Integer(7u32.into()), + ), + ]); + let mut data = buf.as_slice(); + let v = rmpv::decode::read_value_ref(&mut data).unwrap(); + let (_, _, ttl) = Response::parse(v, 10).unwrap().into_parts().unwrap(); + assert_eq!(ttl, 7); + + // non-map + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &ValueRef::String("x".into())).unwrap(); + let mut data = buf.as_slice(); + let v = rmpv::decode::read_value_ref(&mut data).unwrap(); + assert!(Response::parse(v, 10).is_err()); + + // missing cert / key + let empty = Response::new(10); + assert!(empty.into_parts().is_err()); + } +} diff --git a/lib/vey-clap/src/data.rs b/lib/vey-clap/src/data.rs index c7b33e979..fadce8005 100644 --- a/lib/vey-clap/src/data.rs +++ b/lib/vey-clap/src/data.rs @@ -28,3 +28,98 @@ pub fn get(args: &ArgMatches, id: &str, decode_binary: bool) -> anyhow::Result) -> clap::ArgMatches { + let command = + Command::new("test").arg(Arg::new("data").long("data").action(ArgAction::Set)); + if let Some(v) = value { + command.get_matches_from(vec!["test", &format!("--data={v}")]) + } else { + command.get_matches_from(vec!["test"]) + } + } + + fn unique_path(prefix: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("vey-clap-{prefix}-{nanos}-{n}")) + } + + #[test] + fn get_missing_returns_empty() { + let args = create_args(None); + assert!(get(&args, "data", false).unwrap().is_empty()); + assert!(get(&args, "data", true).unwrap().is_empty()); + } + + #[test] + fn get_literal_text_and_hex() { + let args = create_args(Some("hello")); + assert_eq!(get(&args, "data", false).unwrap(), b"hello"); + + let args = create_args(Some("68656c6c6f")); + assert_eq!(get(&args, "data", true).unwrap(), b"hello"); + + let args = create_args(Some("not-hex")); + assert!(get(&args, "data", true).is_err()); + } + + #[test] + fn get_from_file() { + let path = unique_path("file"); + fs::write(&path, b"from-file").unwrap(); + let arg = format!("@{}", path.display()); + let args = create_args(Some(&arg)); + assert_eq!(get(&args, "data", false).unwrap(), b"from-file"); + + fs::write(&path, b"deadbeef").unwrap(); + let args = create_args(Some(&arg)); + assert_eq!(get(&args, "data", true).unwrap(), [0xde, 0xad, 0xbe, 0xef]); + + let args = create_args(Some("@/definitely/missing/vey-clap-data.bin")); + assert!(get(&args, "data", false).is_err()); + + let _ = fs::remove_file(&path); + } + + #[test] + fn get_from_env() { + let key = format!( + "VEY_CLAP_TEST_DATA_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + // SAFETY: unique key only used in this test process. + unsafe { std::env::set_var(&key, "env-value") }; + + let arg = format!("${key}"); + let args = create_args(Some(&arg)); + assert_eq!(get(&args, "data", false).unwrap(), b"env-value"); + + unsafe { std::env::set_var(&key, "616263") }; + let args = create_args(Some(&arg)); + assert_eq!(get(&args, "data", true).unwrap(), b"abc"); + + let args = create_args(Some("$VEY_CLAP_TEST_DATA_MISSING_XYZ")); + assert!(get(&args, "data", false).is_err()); + + unsafe { std::env::remove_var(&key) }; + } +} diff --git a/lib/vey-clap/src/http/header.rs b/lib/vey-clap/src/http/header.rs index 24ed9161b..455d83bff 100644 --- a/lib/vey-clap/src/http/header.rs +++ b/lib/vey-clap/src/http/header.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::str::FromStr; @@ -74,6 +75,13 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].0, "x-empty-header"); assert_eq!(result[0].1, ""); + + // Value may contain additional colons (split on first ':') + let args = create_args(&["X-Time:12:34:56"]); + let result = get_headers(&args, "headers").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "x-time"); + assert_eq!(result[0].1, "12:34:56"); } #[test] diff --git a/lib/vey-clap/src/humanize/size.rs b/lib/vey-clap/src/humanize/size.rs index dfd6edf0e..82eaa67bb 100644 --- a/lib/vey-clap/src/humanize/size.rs +++ b/lib/vey-clap/src/humanize/size.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::str::FromStr; @@ -59,11 +60,18 @@ mod tests { ("1KB", 1000), ("1M", 1000 * 1000), ("1G", 1000 * 1000 * 1000), + ("1KiB", 1024), + ("1MiB", 1024 * 1024), + ("2 KiB", 2048), ]; for (input, expected) in cases { let args = create_args(Some(input)); - assert_eq!(get_usize(&args, "size").unwrap(), Some(expected)); + assert_eq!( + get_usize(&args, "size").unwrap(), + Some(expected), + "input {input}" + ); } } diff --git a/lib/vey-clap/src/limit/rate.rs b/lib/vey-clap/src/limit/rate.rs index 2d8824968..f0969a2b6 100644 --- a/lib/vey-clap/src/limit/rate.rs +++ b/lib/vey-clap/src/limit/rate.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::num::NonZeroU32; @@ -39,3 +40,90 @@ pub fn get_rate_limit(args: &ArgMatches, id: &str) -> anyhow::Result) -> clap::ArgMatches { + let command = + Command::new("test").arg(Arg::new("rate").long("rate").action(ArgAction::Set)); + if let Some(v) = value { + command.get_matches_from(vec!["test", &format!("--rate={v}")]) + } else { + command.get_matches_from(vec!["test"]) + } + } + + fn nz(v: u32) -> NonZeroU32 { + NonZeroU32::new(v).unwrap() + } + + #[test] + fn get_rate_limit_none() { + let args = create_args(None); + assert!(get_rate_limit(&args, "rate").unwrap().is_none()); + } + + #[test] + fn get_rate_limit_ok() { + let args = create_args(Some("10")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::per_second(nz(10)).unwrap()) + ); + + let args = create_args(Some("10/s")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::per_second(nz(10)).unwrap()) + ); + + let args = create_args(Some("10/m")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::per_minute(nz(10)).unwrap()) + ); + + let args = create_args(Some("10/h")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::per_hour(nz(10)).unwrap()) + ); + + let args = create_args(Some("5/2")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::new(Duration::from_secs(2), nz(5)).unwrap()) + ); + + let args = create_args(Some("5/2s")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::new(Duration::from_secs(2), nz(5)).unwrap()) + ); + + let args = create_args(Some(" 8 / s ")); + assert_eq!( + get_rate_limit(&args, "rate").unwrap(), + Some(RateLimitQuota::per_second(nz(8)).unwrap()) + ); + } + + #[test] + fn get_rate_limit_err() { + for input in ["0", "abc", "10/x", "10/0", "-1/s", ""] { + let args = create_args(Some(input)); + assert!( + get_rate_limit(&args, "rate").is_err(), + "expected error for {input:?}" + ); + } + } +} diff --git a/lib/vey-codec/src/ber/integer.rs b/lib/vey-codec/src/ber/integer.rs index 28e387e9f..1a275233c 100644 --- a/lib/vey-codec/src/ber/integer.rs +++ b/lib/vey-codec/src/ber/integer.rs @@ -194,4 +194,23 @@ mod tests { .unwrap(); assert_eq!(v.value(), i64::MIN); } + + #[test] + fn parse_enumerated_and_reject_too_large() { + let v = BerInteger::parse_enumerated_value(&[0x0a, 0x01, 0x05]).unwrap(); + assert_eq!(v.value(), 5); + assert_eq!(v.encoded_len(), 3); + + // Wrong identifier for enumerated + assert_eq!( + BerInteger::parse_enumerated_value(&[0x02, 0x01, 0x05]).unwrap_err(), + BerIntegerParseError::InvalidType + ); + + // Content longer than 8 octets + assert_eq!( + BerInteger::parse(&[0x02, 0x09, 0, 0, 0, 0, 0, 0, 0, 0, 1]).unwrap_err(), + BerIntegerParseError::InvalidValueBytes + ); + } } diff --git a/lib/vey-codec/src/leb128.rs b/lib/vey-codec/src/leb128.rs index 73891b18e..69a8adc12 100644 --- a/lib/vey-codec/src/leb128.rs +++ b/lib/vey-codec/src/leb128.rs @@ -110,4 +110,29 @@ mod tests { assert_eq!(encoder.encode_u32(1), &[0x01]); assert_eq!(encoder.encode_u32(624485), &[0xE5, 0x8E, 0x26]); } + + #[test] + fn roundtrip_and_errors() { + let mut encoder = Leb128Encoder::default(); + for v in [0u32, 127, 128, 255, 624485, u32::MAX] { + let encoded = encoder.encode_u32(v).to_vec(); + let decoded = Leb128::::decode(&encoded).unwrap(); + assert_eq!(decoded.value(), v); + assert_eq!(decoded.encoded_len(), encoded.len()); + } + + assert!(matches!( + Leb128::::decode(&[]), + Err(Leb128DecodeError::NeedMoreData) + )); + assert!(matches!( + Leb128::::decode(&[0x80]), + Err(Leb128DecodeError::NeedMoreData) + )); + // More than 5 continuation bytes for u32 + assert!(matches!( + Leb128::::decode(&[0x80, 0x80, 0x80, 0x80, 0x80, 0x01]), + Err(Leb128DecodeError::NoEndFound) + )); + } } diff --git a/lib/vey-codec/src/thrift/var_int.rs b/lib/vey-codec/src/thrift/var_int.rs index c3e7f5441..86accc877 100644 --- a/lib/vey-codec/src/thrift/var_int.rs +++ b/lib/vey-codec/src/thrift/var_int.rs @@ -51,3 +51,40 @@ impl VarIntEncoder { self.leb128.encode_u32(i32::cast_unsigned(v)) } } + +#[cfg(test)] +mod tests { + use super::{VarInt32, VarIntEncoder}; + + #[test] + fn zigzag_roundtrip() { + let mut enc = VarIntEncoder::default(); + for v in [0, 1, -1, 2, -2, i32::MAX, i32::MIN, 12345, -67890] { + let encoded = enc.encode_i32(v).to_vec(); + let parsed = VarInt32::parse(&encoded).unwrap(); + assert_eq!(parsed.value(), v, "zigzag value for {v}"); + assert_eq!(parsed.encoded_len(), encoded.len()); + } + } + + #[test] + fn positive_varint_no_zigzag() { + let mut enc = VarIntEncoder::default(); + let encoded = enc.encode_positive_i32(150).to_vec(); + let parsed = VarInt32::parse(&encoded).unwrap(); + assert_eq!(parsed.positive_value(), 150); + // Zigzag decode of the same bytes is a different integer. + assert_ne!(parsed.value(), 150); + + let encoded = enc.encode_positive_i32(1).to_vec(); + assert_eq!(encoded, [0x01]); + assert_eq!(VarInt32::parse(&encoded).unwrap().positive_value(), 1); + } + + #[test] + fn parse_errors() { + assert!(VarInt32::parse(&[]).is_err()); + // Continuation bit set with no following byte + assert!(VarInt32::parse(&[0x80]).is_err()); + } +} diff --git a/lib/vey-codec/src/tls/extension.rs b/lib/vey-codec/src/tls/extension.rs index dc5286f42..7f540a167 100644 --- a/lib/vey-codec/src/tls/extension.rs +++ b/lib/vey-codec/src/tls/extension.rs @@ -1,11 +1,12 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use thiserror::Error; -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExtensionType { ServerName, // rfc6066 MaxFragmentLength, // rfc6066 @@ -161,10 +162,102 @@ impl<'a> Iterator for ExtensionIter<'a> { self.offset += Extension::HEADER_LEN + ext.ext_len as usize; Some(Ok(ext)) } - Err(e) => Some(Err(e)), + Err(e) => { + // Fuse on error so callers like `.collect()` cannot spin forever. + self.offset = self.data.len(); + Some(Err(e)) + } } } else { None } } } + +#[cfg(test)] +mod tests { + use super::{Extension, ExtensionIter, ExtensionList, ExtensionParseError, ExtensionType}; + + #[test] + fn extension_type_from_u16() { + assert_eq!(ExtensionType::from(0), ExtensionType::ServerName); + assert_eq!( + ExtensionType::from(16), + ExtensionType::ApplicationLayerProtocolNegotiation + ); + assert_eq!(ExtensionType::from(43), ExtensionType::SupportedVersions); + assert_eq!(ExtensionType::from(51), ExtensionType::KeyShare); + assert_eq!(ExtensionType::from(0x1234), ExtensionType::Unknown(0x1234)); + } + + #[test] + fn parse_extension_ok_and_errors() { + // type=0 (SNI), len=0 + let ext = Extension::parse(&[0x00, 0x00, 0x00, 0x00]).unwrap(); + assert_eq!(ext.r#type(), ExtensionType::ServerName); + assert!(ext.data().is_none()); + + // type=16, len=3, data=abc + let ext = Extension::parse(&[0x00, 0x10, 0x00, 0x03, b'a', b'b', b'c']).unwrap(); + assert_eq!( + ext.r#type(), + ExtensionType::ApplicationLayerProtocolNegotiation + ); + assert_eq!(ext.data(), Some(b"abc".as_slice())); + + assert!(matches!( + Extension::parse(&[0x00, 0x00, 0x00]), + Err(ExtensionParseError::NotEnoughData) + )); + assert!(matches!( + Extension::parse(&[0x00, 0x00, 0x00, 0x02, 0x01]), + Err(ExtensionParseError::InvalidLength) + )); + } + + #[test] + fn extension_list_get_and_iter() { + // SNI empty + ALPN "h2" + let data = [ + 0x00, 0x00, 0x00, 0x00, // SNI len 0 + 0x00, 0x10, 0x00, 0x02, b'h', b'2', // ALPN + ]; + assert_eq!( + ExtensionList::get_ext(&data, ExtensionType::ServerName).unwrap(), + None + ); + assert_eq!( + ExtensionList::get_ext(&data, ExtensionType::ApplicationLayerProtocolNegotiation) + .unwrap(), + Some(b"h2".as_slice()) + ); + assert_eq!( + ExtensionList::get_ext(&data, ExtensionType::KeyShare).unwrap(), + None + ); + + let items: Vec<_> = ExtensionIter::new(&data).collect(); + assert_eq!(items.len(), 2); + assert_eq!( + items[0].as_ref().unwrap().r#type(), + ExtensionType::ServerName + ); + assert_eq!( + items[1].as_ref().unwrap().r#type(), + ExtensionType::ApplicationLayerProtocolNegotiation + ); + } + + #[test] + fn extension_iter_fuses_on_parse_error() { + // Valid empty SNI, then a truncated extension (claimed len=2, only 1 byte left). + let data = [ + 0x00, 0x00, 0x00, 0x00, // SNI len 0 + 0x00, 0x10, 0x00, 0x02, 0x01, // truncated ALPN + ]; + let items: Vec<_> = ExtensionIter::new(&data).collect(); + assert_eq!(items.len(), 2); + assert!(items[0].is_ok()); + assert!(matches!(items[1], Err(ExtensionParseError::InvalidLength))); + } +} diff --git a/lib/vey-codec/src/tlv.rs b/lib/vey-codec/src/tlv.rs index 18375e67d..3ea9683c8 100644 --- a/lib/vey-codec/src/tlv.rs +++ b/lib/vey-codec/src/tlv.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ pub trait TlvParse<'a> { @@ -73,3 +74,61 @@ where self.parse_value(tag, buf) } } + +#[cfg(test)] +mod tests { + use super::{T1L2BVParse, TlvParse}; + + #[derive(Default)] + struct Collector { + values: Vec<(u8, Vec)>, + } + + #[derive(Debug, PartialEq, Eq)] + enum Err { + Truncated, + BadTag, + } + + impl<'a> T1L2BVParse<'a> for Collector { + type Error = Err; + + fn no_enough_data() -> Self::Error { + Err::Truncated + } + + fn parse_value(&mut self, tag: u8, buf: &'a [u8]) -> Result<(), Self::Error> { + if tag == 0xFF { + return Err(Err::BadTag); + } + self.values.push((tag, buf.to_vec())); + Ok(()) + } + } + + #[test] + fn parse_t1l2_ok() { + // tag=1, len=2, value=ab ; tag=2, len=0 + let data = [0x01, 0x00, 0x02, b'a', b'b', 0x02, 0x00, 0x00]; + let mut c = Collector::default(); + c.parse_tlv(&data).unwrap(); + assert_eq!(c.values, vec![(1, b"ab".to_vec()), (2, Vec::new())]); + } + + #[test] + fn parse_t1l2_truncated() { + let mut c = Collector::default(); + assert_eq!(c.parse_tlv(&[0x01, 0x00]).unwrap_err(), Err::Truncated); + assert_eq!( + c.parse_tlv(&[0x01, 0x00, 0x02, b'a']).unwrap_err(), + Err::Truncated + ); + } + + #[test] + fn parse_t1l2_propagates_value_error() { + let data = [0xFF, 0x00, 0x01, 0x00]; + let mut c = Collector::default(); + assert_eq!(c.parse_tlv(&data).unwrap_err(), Err::BadTag); + } +} diff --git a/lib/vey-compat/src/sched/mod.rs b/lib/vey-compat/src/sched/mod.rs index b2ab38d7c..edc12a494 100644 --- a/lib/vey-compat/src/sched/mod.rs +++ b/lib/vey-compat/src/sched/mod.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -84,7 +85,7 @@ impl CpuAffinity { Some((s1, s2)) => { let start = CpuId::from_str(s1)?; let end = CpuId::from_str(s2)?; - if start >= end { + if start > end { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("invalid CPU ID range {part}"), @@ -144,4 +145,38 @@ mod tests { affinity.parse_add("0-1,4").unwrap(); assert_eq!(affinity.cpu_id_list(), &[0, 1, 4]); } + + #[test] + fn parse_add_skips_empty_parts() { + let mut affinity = CpuAffinity::default(); + affinity.parse_add(" 1 , ,2 ").unwrap(); + assert_eq!(affinity.cpu_id_list(), &[1, 2]); + } + + #[test] + fn parse_add_accepts_single_id_range() { + let mut affinity = CpuAffinity::default(); + affinity.parse_add("1-1,4-4").unwrap(); + assert_eq!(affinity.cpu_id_list(), &[1, 4]); + } + + #[test] + fn parse_add_rejects_inverted_range() { + let mut affinity = CpuAffinity::default(); + assert!(affinity.parse_add("2-1").is_err()); + } + + #[test] + fn parse_add_rejects_non_numeric() { + let mut affinity = CpuAffinity::default(); + let err = affinity.parse_add("abc").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn add_id_rejects_out_of_range() { + let mut affinity = CpuAffinity::default(); + let err = affinity.add_id(usize::MAX).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } } diff --git a/lib/vey-ctl/src/error.rs b/lib/vey-ctl/src/error.rs index b8842093e..53db41e7a 100644 --- a/lib/vey-ctl/src/error.rs +++ b/lib/vey-ctl/src/error.rs @@ -39,3 +39,42 @@ impl CommandError { } pub type CommandResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_error_ok_reason() { + let reader = capnp::text::Reader(b"upstream unavailable"); + let err = CommandError::api_error(503, reader); + match err { + CommandError::Api { code, reason } => { + assert_eq!(code, 503); + assert_eq!(reason, "upstream unavailable"); + } + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn api_error_invalid_utf8_reason() { + let reader = capnp::text::Reader(&[0xff, 0xfe]); + let err = CommandError::api_error(1, reader); + match err { + CommandError::Utf8 { field, .. } => assert_eq!(field, "reason"), + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn display_includes_context() { + let err = CommandError::Api { + code: 42, + reason: "bad request".to_string(), + }; + let msg = format!("{err}"); + assert!(msg.contains("42")); + assert!(msg.contains("bad request")); + } +} diff --git a/lib/vey-ctl/src/io.rs b/lib/vey-ctl/src/io.rs index 92b1c84d3..4866f35e2 100644 --- a/lib/vey-ctl/src/io.rs +++ b/lib/vey-ctl/src/io.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use super::{CommandError, CommandResult}; @@ -58,3 +59,57 @@ pub fn print_data_list(list: capnp::data_list::Reader<'_>) -> CommandResult<()> } Ok(()) } + +#[cfg(test)] +mod tests { + use capnp::message; + + use super::*; + + #[test] + fn print_ok_notice_ok() { + let reader = capnp::text::Reader(b"reloaded"); + assert!(print_ok_notice(reader).is_ok()); + } + + #[test] + fn print_text_ok() { + let reader = capnp::text::Reader(b"1.2.3"); + assert!(print_text("version", reader).is_ok()); + } + + #[test] + fn print_text_invalid_utf8() { + let reader = capnp::text::Reader(&[0xff]); + let err = print_text("version", reader).unwrap_err(); + match err { + CommandError::Utf8 { field, .. } => assert_eq!(field, "version"), + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn print_data_encodes_hex() { + print_data(&[0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn print_text_list_ok() { + let mut message = message::Builder::new_default(); + message.set_root(&["alpha", "beta"] as &[&str]).unwrap(); + let reader = message + .get_root_as_reader::() + .unwrap(); + assert!(print_text_list("result", reader).is_ok()); + } + + #[test] + fn print_data_list_empty() { + let mut message = message::Builder::new_default(); + message.init_root::(); + let reader = message + .get_root_as_reader::() + .unwrap(); + assert!(print_data_list(reader).is_ok()); + } +} diff --git a/lib/vey-ctl/src/opts.rs b/lib/vey-ctl/src/opts.rs index 8a603b12e..413b4c062 100644 --- a/lib/vey-ctl/src/opts.rs +++ b/lib/vey-ctl/src/opts.rs @@ -202,3 +202,70 @@ impl DaemonCtlArgsExt for Command { ) } } + +#[cfg(test)] +mod tests { + use clap::Command; + + use super::*; + + fn ctl_command() -> Command { + Command::new("vey-ctl-test").append_daemon_ctl_args() + } + + #[test] + fn clap_requires_group_or_pid_unless_completion() { + let cmd = ctl_command(); + assert!(cmd.try_get_matches_from(["vey-ctl-test"]).is_err()); + } + + #[test] + fn clap_accepts_daemon_group_and_pid() { + let cmd = ctl_command(); + let matches = cmd + .try_get_matches_from(["vey-ctl-test", "-G", "workers", "-p", "4242"]) + .unwrap(); + assert_eq!( + matches + .get_one::("daemon-group") + .map(String::as_str), + Some("workers") + ); + assert_eq!(matches.get_one::("pid"), Some(&4242)); + let _ = DaemonCtlArgs::parse_clap(&matches); + } + + #[test] + fn parse_clap_completion_enables_generate_shell_completion() { + let cmd = ctl_command(); + let matches = cmd + .try_get_matches_from(["vey-ctl-test", "--completion", "bash"]) + .unwrap(); + let mut args = DaemonCtlArgs::parse_clap(&matches); + assert!(args.generate_shell_completion(|| Command::new("vey-ctl-test"))); + assert!(!args.generate_shell_completion(|| Command::new("vey-ctl-test"))); + } + + #[test] + fn clap_control_dir_unix() { + #[cfg(unix)] + { + let cmd = ctl_command(); + let matches = cmd + .try_get_matches_from([ + "vey-ctl-test", + "-G", + "main", + "--control-dir", + "/tmp/custom", + ]) + .unwrap(); + assert_eq!( + matches + .get_one::("control-dir") + .map(|p| p.as_os_str()), + Some(std::ffi::OsStr::new("/tmp/custom")) + ); + } + } +} diff --git a/lib/vey-daemon/src/control/config/mod.rs b/lib/vey-daemon/src/control/config/mod.rs index 36ede56c7..71a48243f 100644 --- a/lib/vey-daemon/src/control/config/mod.rs +++ b/lib/vey-daemon/src/control/config/mod.rs @@ -66,3 +66,33 @@ pub fn load(v: &Yaml) -> anyhow::Result<()> { _ => Err(anyhow!("root value type should be hash")), } } + +#[cfg(test)] +mod tests { + use super::*; + use vey_yaml::yaml_str; + + #[test] + fn default_timeouts() { + let config = GeneralControllerConfig::default(); + assert_eq!(config.recv_timeout, 30); + assert_eq!(config.send_timeout, 1); + } + + #[test] + fn set_updates_timeouts() { + let mut config = GeneralControllerConfig::new(); + config.set("recv_timeout", &yaml_str!("120")).unwrap(); + config.set("send_timeout", &yaml_str!("5")).unwrap(); + + assert_eq!(config.recv_timeout, 120); + assert_eq!(config.send_timeout, 5); + } + + #[test] + fn set_rejects_unknown_key() { + let mut config = GeneralControllerConfig::new(); + let err = config.set("unknown", &yaml_str!("1")).unwrap_err(); + assert!(err.to_string().contains("invalid key")); + } +} diff --git a/lib/vey-daemon/src/metrics/helper.rs b/lib/vey-daemon/src/metrics/helper.rs index c4f61f7c1..9fcd36806 100644 --- a/lib/vey-daemon/src/metrics/helper.rs +++ b/lib/vey-daemon/src/metrics/helper.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::sync::Mutex; @@ -22,3 +23,34 @@ pub fn move_ht(in_ht_lock: &Mutex>, out_ht_lock: &Mutex::new()); + let out_ht = Mutex::new(GlobalStatsMap::::new()); + let id = StatId::new_unique(); + + in_ht.lock().unwrap().insert(id, 42); + move_ht(&in_ht, &out_ht); + + assert!(in_ht.lock().unwrap().is_empty()); + assert_eq!(*out_ht.lock().unwrap().get_or_insert_with(id, || 0), 42); + } + + #[test] + fn move_ht_noop_when_source_empty() { + let in_ht = Mutex::new(GlobalStatsMap::::new()); + let out_ht = Mutex::new(GlobalStatsMap::::new()); + let id = StatId::new_unique(); + out_ht.lock().unwrap().insert(id, 7); + + move_ht(&in_ht, &out_ht); + assert_eq!(*out_ht.lock().unwrap().get_or_insert_with(id, || 0), 7); + } +} diff --git a/lib/vey-daemon/src/runtime/config.rs b/lib/vey-daemon/src/runtime/config.rs index 38485a5f0..da9abb2d5 100644 --- a/lib/vey-daemon/src/runtime/config.rs +++ b/lib/vey-daemon/src/runtime/config.rs @@ -116,3 +116,37 @@ fn set_global_config(k: &str, v: &Yaml) -> anyhow::Result<()> { _ => RUNTIME_CONFIG.with_mut(|config| config.parse_by_yaml_kv(k, v)), } } + +#[cfg(test)] +mod tests { + use super::*; + use vey_yaml::yaml_doc; + + #[test] + fn load_updates_graceful_wait_durations() { + let yaml = yaml_doc!( + r#" + server_offline_delay: 10s + task_wait_delay: 3s + "# + ); + + load(&yaml).unwrap(); + + assert_eq!(get_server_offline_delay(), Duration::from_secs(10)); + assert_eq!(get_task_wait_delay(), Duration::from_secs(3)); + } + + #[test] + fn load_rejects_non_hash_root() { + let err = load(&yaml_rust::Yaml::Integer(1)).unwrap_err(); + assert!(err.to_string().contains("root value type should be hash")); + } + + #[test] + fn load_parses_task_wait_delay_alone() { + let yaml = yaml_doc!(r#"task_wait_delay: 500ms"#); + load(&yaml).unwrap(); + assert_eq!(get_task_wait_delay(), Duration::from_millis(500)); + } +} diff --git a/lib/vey-daemon/src/server/quit_policy.rs b/lib/vey-daemon/src/server/quit_policy.rs index f9e63cf84..c271bcd6f 100644 --- a/lib/vey-daemon/src/server/quit_policy.rs +++ b/lib/vey-daemon/src/server/quit_policy.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::sync::atomic::{AtomicBool, Ordering}; @@ -36,3 +37,26 @@ impl ServerQuitPolicy { self.force_quit_scheduled.store(true, Ordering::Relaxed); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_not_forced() { + let policy = ServerQuitPolicy::default(); + assert!(!policy.force_quit()); + assert!(!policy.force_quit_scheduled()); + } + + #[test] + fn force_quit_flags_are_independent() { + let policy = ServerQuitPolicy::default(); + policy.set_force_quit(); + assert!(policy.force_quit()); + assert!(!policy.force_quit_scheduled()); + + policy.set_force_quit_scheduled(); + assert!(policy.force_quit_scheduled()); + } +} diff --git a/lib/vey-daemon/src/stat/config.rs b/lib/vey-daemon/src/stat/config.rs index d409ba951..ee3611a70 100644 --- a/lib/vey-daemon/src/stat/config.rs +++ b/lib/vey-daemon/src/stat/config.rs @@ -33,3 +33,28 @@ pub fn load(v: &Yaml, prefix: &str) -> anyhow::Result<()> { set_global_stat_config(config); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use vey_yaml::yaml_doc; + + #[test] + fn load_accepts_valid_yaml() { + let yaml = yaml_doc!( + r#" + target_udp: "127.0.0.1:8125" + cache_size: 1024 + "# + ); + load(&yaml, "daemon.metrics").unwrap(); + assert!(get_global_stat_config().is_some()); + } + + #[test] + fn load_rejects_invalid_prefix() { + let yaml = yaml_doc!(r#"target_udp: "127.0.0.1:8125""#); + let err = load(&yaml, "not a valid node name!!!").unwrap_err(); + assert!(err.to_string().contains("invalid default metrics prefix")); + } +} diff --git a/lib/vey-datetime/Cargo.toml b/lib/vey-datetime/Cargo.toml index c3c9b72e3..3e4e0ba92 100644 --- a/lib/vey-datetime/Cargo.toml +++ b/lib/vey-datetime/Cargo.toml @@ -7,4 +7,4 @@ edition.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono.workspace = true +chrono = { workspace = true, features = ["alloc", "clock"] } diff --git a/lib/vey-datetime/src/format/mod.rs b/lib/vey-datetime/src/format/mod.rs index 6da3e7bb3..deacdbb70 100644 --- a/lib/vey-datetime/src/format/mod.rs +++ b/lib/vey-datetime/src/format/mod.rs @@ -7,3 +7,6 @@ pub mod asn1; pub mod ftp; pub mod log; pub mod std; + +#[cfg(test)] +mod tests; diff --git a/lib/vey-datetime/src/format/tests.rs b/lib/vey-datetime/src/format/tests.rs new file mode 100644 index 000000000..93d42db89 --- /dev/null +++ b/lib/vey-datetime/src/format/tests.rs @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. + */ + +use chrono::format::{Parsed, parse}; +use chrono::{DateTime, TimeZone, Utc}; + +use super::{asn1, ftp, log, std}; + +fn format_utc(fmt: &[chrono::format::Item<'_>], dt: &DateTime) -> String { + dt.format_with_items(fmt.iter()).to_string() +} + +fn parse_utc(fmt: &[chrono::format::Item<'_>], s: &str) -> DateTime { + let mut parsed = Parsed::new(); + parse(&mut parsed, s, fmt.iter()).unwrap(); + parsed.to_datetime_with_timezone(&Utc).unwrap() +} + +#[test] +fn rfc3339_fixed_microsecond_formats_and_roundtrips() { + let dt = Utc.with_ymd_and_hms(2021, 12, 1, 10, 20, 30).unwrap(); + assert_eq!( + format_utc(std::RFC3339_FIXED_MICROSECOND, &dt), + "2021-12-01T10:20:30.000000Z" + ); + + let with_frac = DateTime::parse_from_rfc3339("2021-12-01T10:20:30.123456789Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!( + format_utc(std::RFC3339_FIXED_MICROSECOND, &with_frac), + "2021-12-01T10:20:30.123456Z" + ); +} + +#[test] +fn rfc5424_matches_syslog_timestamp_shape() { + let dt = DateTime::parse_from_rfc3339("2021-12-01T10:20:30.123456789Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(format_utc(log::RFC5424, &dt), "2021-12-01T10:20:30.123456Z"); + + let dt = DateTime::parse_from_rfc3339("2021-12-01T10:20:30+08:00") + .unwrap() + .with_timezone(&Utc); + assert_eq!(format_utc(log::RFC5424, &dt), "2021-12-01T02:20:30.000000Z"); +} + +#[test] +fn rfc3164_and_stdio_log_formats() { + let dt = Utc.with_ymd_and_hms(2021, 12, 1, 10, 20, 30).unwrap(); + assert_eq!(format_utc(log::RFC3164, &dt), "Dec 1 10:20:30"); + assert_eq!(format_utc(log::STDIO, &dt), "Dec 01 10:20:30.000"); +} + +#[test] +fn rfc3659_ftp_format_parses_compact_timestamp() { + let dt = parse_utc(ftp::RFC3659, "20211201102030"); + let expected = DateTime::parse_from_rfc3339("2021-12-01T10:20:30+00:00") + .unwrap() + .with_timezone(&Utc); + assert_eq!(dt, expected); + + let dt = parse_utc(ftp::RFC3659, "20211201102030.123"); + let expected = DateTime::parse_from_rfc3339("2021-12-01T10:20:30.123+00:00") + .unwrap() + .with_timezone(&Utc); + assert_eq!(dt, expected); + assert_eq!(format_utc(ftp::RFC3659, &dt), "20211201102030.123"); +} + +#[test] +fn asn1_rfc5280_formats() { + let dt = Utc.with_ymd_and_hms(2021, 12, 1, 10, 20, 30).unwrap(); + assert_eq!(format_utc(asn1::RFC5280_UTC, &dt), "211201102030Z"); + assert_eq!( + format_utc(asn1::RFC5280_GENERALIZED, &dt), + "20211201102030Z" + ); + + let future = Utc.with_ymd_and_hms(2050, 1, 2, 3, 4, 5).unwrap(); + assert_eq!( + format_utc(asn1::RFC5280_GENERALIZED, &future), + "20500102030405Z" + ); +} diff --git a/lib/vey-fluentd/src/format.rs b/lib/vey-fluentd/src/format.rs index 7070c51a2..7bab0bc78 100644 --- a/lib/vey-fluentd/src/format.rs +++ b/lib/vey-fluentd/src/format.rs @@ -192,3 +192,34 @@ impl Serializer for FormatterKv<'_> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emit_u64_writes_key_and_value() { + let mut buf = Vec::new(); + let mut fmt = FormatterKv(&mut buf); + fmt.emit_u64("count".into(), 42).unwrap(); + assert!(buf.windows(5).any(|w| w == b"count")); + } + + #[test] + fn emit_none_writes_nil() { + let mut buf = Vec::new(); + let mut fmt = FormatterKv(&mut buf); + fmt.emit_none("empty".into()).unwrap(); + assert!(buf.windows(5).any(|w| w == b"empty")); + assert!(buf.contains(&0xc0)); + } + + #[test] + fn emit_str_writes_key_and_value() { + let mut buf = Vec::new(); + let mut fmt = FormatterKv(&mut buf); + fmt.emit_str("msg".into(), "hello").unwrap(); + assert!(buf.windows(3).any(|w| w == b"msg")); + assert!(buf.windows(5).any(|w| w == b"hello")); + } +} diff --git a/lib/vey-ftp-client/src/control/response.rs b/lib/vey-ftp-client/src/control/response.rs index 9b73ae2c4..c37cf01ed 100644 --- a/lib/vey-ftp-client/src/control/response.rs +++ b/lib/vey-ftp-client/src/control/response.rs @@ -298,3 +298,64 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[test] + fn parse_single_line() { + let rsp = FtpRawResponse::parse_single_line("220 Service ready").unwrap(); + assert_eq!(rsp.code(), 220); + assert_eq!(rsp.line_trimmed(), Some("Service ready")); + assert!(rsp.lines().is_none()); + } + + #[test] + fn parse_code_bytes_rejects_out_of_range() { + assert!(FtpRawResponse::parse_code_bytes(b'0', b'0', b'0').is_err()); + assert!(FtpRawResponse::parse_code_bytes(b'6', b'0', b'0').is_err()); + assert!(FtpRawResponse::parse_code_bytes(b'2', b'2', b'0').is_ok()); + } + + #[test] + fn parse_pasv_227_reply() { + let rsp = + FtpRawResponse::parse_single_line("227 Entering Passive Mode (192,168,1,1,195,149)") + .unwrap(); + let addr = rsp.parse_pasv_227_reply().unwrap(); + assert_eq!( + addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 50069) + ); + } + + #[test] + fn parse_epsv_229_reply() { + let rsp = + FtpRawResponse::parse_single_line("229 Entering Extended Passive Mode (|||6446|)") + .unwrap(); + assert_eq!(rsp.parse_epsv_229_reply(), Some(6446)); + } + + #[test] + fn parse_spsv_227_reply() { + let rsp = FtpRawResponse::parse_single_line("227 (abc123)").unwrap(); + assert_eq!(rsp.parse_spsv_227_reply(), Some("abc123".to_owned())); + } + + #[test] + fn multi_line_parser() { + let mut parser = FtpRawResponse::get_multi_line_parser("211-Features:", 8).unwrap(); + assert!(!parser.feed_line(" UTF8").unwrap()); + assert!(!parser.feed_line(" SIZE").unwrap()); + assert!(parser.feed_line("211 End").unwrap()); + let rsp = parser.finish(); + assert_eq!(rsp.code(), 211); + let lines = rsp.lines().unwrap(); + assert_eq!(lines.len(), 4); + assert_eq!(lines[0], "Features:"); + assert_eq!(lines[3], "End"); + } +} diff --git a/lib/vey-ftp-client/src/facts/entry_type.rs b/lib/vey-ftp-client/src/facts/entry_type.rs index 9feeb7da6..6feb5eb8a 100644 --- a/lib/vey-ftp-client/src/facts/entry_type.rs +++ b/lib/vey-ftp-client/src/facts/entry_type.rs @@ -64,3 +64,39 @@ impl FtpFileEntryType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_known_types() { + assert_eq!(FtpFileEntryType::parse("file"), FtpFileEntryType::File); + assert_eq!(FtpFileEntryType::parse("DIR"), FtpFileEntryType::Directory); + assert_eq!( + FtpFileEntryType::parse("cdir"), + FtpFileEntryType::CurrentDir + ); + assert_eq!(FtpFileEntryType::parse("pdir"), FtpFileEntryType::ParentDir); + } + + #[test] + fn parse_os_specific_type() { + let t = FtpFileEntryType::parse("unix.slink"); + assert_eq!(t.as_str(), "unix.slink"); + assert!(t.maybe_file()); + assert!(!t.is_dir()); + } + + #[test] + fn display_roundtrip() { + for t in [ + FtpFileEntryType::File, + FtpFileEntryType::Directory, + FtpFileEntryType::CurrentDir, + FtpFileEntryType::ParentDir, + ] { + assert_eq!(format!("{t}"), t.as_str()); + } + } +} diff --git a/lib/vey-ftp-client/src/feature.rs b/lib/vey-ftp-client/src/feature.rs index 5fa1ec408..2ce0692ed 100644 --- a/lib/vey-ftp-client/src/feature.rs +++ b/lib/vey-ftp-client/src/feature.rs @@ -74,3 +74,43 @@ impl FtpServerFeature { self.single_port_passive } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_and_set_features() { + let mut feat = FtpServerFeature::default(); + for line in [ + "UTF8", + "SIZE", + "MDTM", + "REST STREAM", + "PRET", + "MLST", + "EPSV", + "SPSV", + "UNKNOWN", + ] { + feat.parse_and_set(line); + } + assert!(feat.support_utf8_path()); + assert!(feat.support_file_size()); + assert!(feat.support_file_mtime()); + assert!(feat.support_rest_stream()); + assert!(feat.support_pre_transfer()); + assert!(feat.support_machine_list()); + assert!(feat.support_epsv()); + assert!(feat.support_spsv()); + } + + #[test] + fn rest_requires_stream_suffix() { + let mut feat = FtpServerFeature::default(); + feat.parse_and_set("REST"); + assert!(!feat.support_rest_stream()); + feat.parse_and_set("REST stream"); + assert!(feat.support_rest_stream()); + } +} diff --git a/lib/vey-geoip-db/src/file/mod.rs b/lib/vey-geoip-db/src/file/mod.rs index d46984e55..ac72c2766 100644 --- a/lib/vey-geoip-db/src/file/mod.rs +++ b/lib/vey-geoip-db/src/file/mod.rs @@ -328,4 +328,13 @@ mod tests { let result = load_asn_from_csv(FailingReader); assert!(result.is_err()); } + + #[test] + fn load_country_ipv6_longest_match() { + let csv = "2001:db8::/32,US\n2001:db8:1::/48,CN\n"; + let table = load_country_from_csv(csv.as_bytes()).unwrap(); + let ip: IpAddr = "2001:db8:1:2::1".parse().unwrap(); + let record = table.longest_match(ip).unwrap(); + assert_eq!(record.1.country, IsoCountryCode::CN); + } } diff --git a/lib/vey-geoip-db/src/store/mod.rs b/lib/vey-geoip-db/src/store/mod.rs index 89a208567..4c9f02121 100644 --- a/lib/vey-geoip-db/src/store/mod.rs +++ b/lib/vey-geoip-db/src/store/mod.rs @@ -30,3 +30,52 @@ pub fn load_asn() -> Option>> { pub fn store_asn(db: Arc>) { GEO_ASN_DB.store(Some(db)); } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + use std::str::FromStr; + + use ip_network::IpNetwork; + use vey_geoip_types::{ContinentCode, IsoCountryCode}; + + #[test] + fn store_and_load_country() { + let mut table = IpNetworkTable::new(); + let network = IpNetwork::new(IpAddr::from_str("10.0.0.0").unwrap(), 8).unwrap(); + table.insert( + network, + GeoIpCountryRecord { + country: IsoCountryCode::US, + continent: ContinentCode::NA, + }, + ); + store_country(Arc::new(table)); + + let loaded = load_country().expect("country db should be stored"); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let record = loaded.longest_match(ip).unwrap(); + assert_eq!(record.1.country, IsoCountryCode::US); + } + + #[test] + fn store_and_load_asn() { + let mut table = IpNetworkTable::new(); + let network = IpNetwork::new(IpAddr::from_str("192.168.0.0").unwrap(), 16).unwrap(); + table.insert( + network, + GeoIpAsnRecord { + number: 64512, + name: None, + domain: None, + }, + ); + store_asn(Arc::new(table)); + + let loaded = load_asn().expect("asn db should be stored"); + let ip: IpAddr = "192.168.1.1".parse().unwrap(); + let record = loaded.longest_match(ip).unwrap(); + assert_eq!(record.1.number, 64512); + } +} diff --git a/lib/vey-geoip-types/src/location.rs b/lib/vey-geoip-types/src/location.rs index 73f5c3656..a0f860b4e 100644 --- a/lib/vey-geoip-types/src/location.rs +++ b/lib/vey-geoip-types/src/location.rs @@ -111,3 +111,38 @@ impl IpLocation { self.isp_domain.as_deref() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + use ip_network::IpNetwork; + + #[test] + fn builder_requires_network() { + let builder = IpLocationBuilder::default(); + assert!(builder.build().is_err()); + } + + #[test] + fn builder_prefers_more_specific_network() { + let broad = IpNetwork::from_str("10.0.0.0/8").unwrap(); + let narrow = IpNetwork::from_str("10.1.2.0/24").unwrap(); + + let mut builder = IpLocationBuilder::default(); + builder.set_network(broad); + builder.set_network(narrow); + let location = builder.build().unwrap(); + assert_eq!(location.network_addr(), narrow); + } + + #[test] + fn continent_derived_from_country() { + let mut builder = IpLocationBuilder::default(); + builder.set_network(IpNetwork::from_str("8.8.8.0/24").unwrap()); + builder.set_country(IsoCountryCode::US); + let location = builder.build().unwrap(); + assert_eq!(location.continent(), Some(ContinentCode::NA)); + } +} diff --git a/lib/vey-h2/src/body/error.rs b/lib/vey-h2/src/body/error.rs index 76e21314f..a4139f9e2 100644 --- a/lib/vey-h2/src/body/error.rs +++ b/lib/vey-h2/src/body/error.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use thiserror::Error; @@ -24,3 +25,14 @@ pub enum H2StreamBodyTransferError { #[error("error while set graceful end of stream: {0}")] GracefulCloseError(h2::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_includes_variant_context() { + let err = H2StreamBodyTransferError::SenderNotInSendState; + assert!(err.to_string().contains("sender not in send state")); + } +} diff --git a/lib/vey-h2/src/body/preview.rs b/lib/vey-h2/src/body/preview.rs index 17f8e74ca..e7f21eb6a 100644 --- a/lib/vey-h2/src/body/preview.rs +++ b/lib/vey-h2/src/body/preview.rs @@ -264,3 +264,48 @@ impl H2PreviewData { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_starts_empty() { + let mut preview = H2PreviewData::new(1024); + assert_eq!(preview.preview_size(), 0); + assert!(!preview.end_of_data()); + assert!(preview.take_left().is_none()); + } + + #[tokio::test] + async fn icap_write_preview_data_formats_chunk() { + let mut preview = H2PreviewData::new(1024); + preview.buffer.extend_from_slice(b"hello"); + preview.received = preview.buffer.len(); + + let mut out = Vec::new(); + preview.icap_write_preview_data(&mut out).await.unwrap(); + + assert_eq!(out, b"5\r\nhello\r\n0\r\n\r\n"); + } + + #[tokio::test] + async fn icap_write_all_as_chunked_writes_zero_chunk_for_empty_body() { + let preview = H2PreviewData::new(1024); + let mut out = Vec::new(); + preview.icap_write_all_as_chunked(&mut out).await.unwrap(); + assert_eq!(out, b"0\r\n"); + } + + #[test] + fn take_left_returns_remaining_bytes() { + let mut preview = H2PreviewData::new(4); + preview.buffer.extend_from_slice(b"abcd"); + preview.received = 8; + preview.left = Some(Bytes::from_static(b"efgh")); + + assert_eq!(preview.preview_size(), 4); + assert_eq!(preview.take_left().unwrap(), Bytes::from_static(b"efgh")); + assert!(preview.take_left().is_none()); + } +} diff --git a/lib/vey-h2/src/ext/request.rs b/lib/vey-h2/src/ext/request.rs index d42e1da23..a6a6de865 100644 --- a/lib/vey-h2/src/ext/request.rs +++ b/lib/vey-h2/src/ext/request.rs @@ -103,3 +103,129 @@ impl RequestExt for Request { false } } + +#[cfg(test)] +mod tests { + use super::*; + use http::Version; + + #[test] + fn serialize_for_adapter_includes_method_and_path() { + let req = Request::builder() + .method(Method::GET) + .uri("http://example.com/foo?bar=1") + .header("X-Test", "value") + .body(()) + .unwrap(); + + let serialized = req.serialize_for_adapter(); + let text = String::from_utf8(serialized).unwrap(); + + assert!(text.starts_with("GET /foo?bar=1 HTTP/1.1\r\n")); + assert!(text.to_ascii_lowercase().contains("x-test: value\r\n")); + assert!(text.ends_with("\r\n\r\n")); + } + + #[test] + fn serialize_for_adapter_options_star() { + let req = Request::builder() + .method(Method::OPTIONS) + .uri("*") + .body(()) + .unwrap(); + + let serialized = req.serialize_for_adapter(); + assert_eq!( + String::from_utf8(serialized).unwrap(), + "OPTIONS * HTTP/1.1\r\n\r\n" + ); + } + + #[test] + fn serialize_for_adapter_skips_te_and_injects_host() { + let req = Request::builder() + .method(Method::POST) + .uri("http://example.com/submit") + .header(header::TE, "trailers") + .header("X-Keep", "yes") + .body(()) + .unwrap(); + + let text = String::from_utf8(req.serialize_for_adapter()).unwrap(); + + assert!(!text.contains("te:")); + assert!(!text.contains("TE:")); + assert!(text.to_ascii_lowercase().contains("x-keep: yes\r\n")); + assert!(text.to_ascii_lowercase().contains("host: example.com\r\n")); + } + + #[test] + fn expect_100_continue_detects_header() { + let req = Request::builder() + .header(header::EXPECT, "100-continue") + .body(()) + .unwrap(); + assert!(req.expect_100_continue()); + + let req = Request::builder() + .header(header::EXPECT, "other") + .body(()) + .unwrap(); + assert!(!req.expect_100_continue()); + } + + #[test] + fn clone_header_copies_parts_without_body() { + let req = Request::builder() + .method(Method::PUT) + .uri("https://example.com/a") + .version(Version::HTTP_2) + .header("X-Test", "1") + .body("body") + .unwrap(); + + let header_only = req.clone_header(); + assert_eq!(header_only.method(), Method::PUT); + assert_eq!(header_only.uri(), req.uri()); + assert_eq!(header_only.version(), Version::HTTP_2); + assert_eq!(header_only.headers().get("X-Test").unwrap(), "1"); + } + + #[test] + fn serialize_for_adapter_default_path_without_query() { + let req = Request::builder() + .method(Method::GET) + .uri("http://example.com") + .body(()) + .unwrap(); + + let text = String::from_utf8(req.serialize_for_adapter()).unwrap(); + assert!(text.starts_with("GET / HTTP/1.1\r\n")); + assert!(text.to_ascii_lowercase().contains("host: example.com\r\n")); + } + + #[tokio::test] + async fn adapt_to_replaces_method_and_uri() { + use tokio::io::BufReader; + use vey_http::server::HttpAdaptedRequest; + + let mut reader = BufReader::new(&b"POST /new HTTP/1.1\r\n\r\n"[..]); + let adapted = HttpAdaptedRequest::parse(&mut reader, 4096, false) + .await + .unwrap(); + + let req = Request::builder() + .method(Method::GET) + .uri("https://old.example/path?x=1") + .header(header::HOST, "old.example") + .header(header::TE, "trailers") + .body("body") + .unwrap(); + + let adapted_req = req.adapt_to(&adapted); + assert_eq!(adapted_req.method(), Method::POST); + assert_eq!(adapted_req.uri().path(), "/new"); + assert_eq!(adapted_req.uri().scheme().unwrap().as_str(), "https"); + assert_eq!(adapted_req.headers().get(header::TE).unwrap(), "trailers"); + } +} diff --git a/lib/vey-h2/src/ext/response.rs b/lib/vey-h2/src/ext/response.rs index 49dbf709c..14c4679f5 100644 --- a/lib/vey-h2/src/ext/response.rs +++ b/lib/vey-h2/src/ext/response.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io::Write; @@ -44,3 +45,55 @@ impl ResponseExt for Response { Response::from_parts(parts, body) } } + +#[cfg(test)] +mod tests { + use super::*; + use http::StatusCode; + + #[test] + fn serialize_for_adapter_includes_status_and_headers() { + let rsp = Response::builder() + .status(StatusCode::NOT_FOUND) + .header("X-Test", "missing") + .body(()) + .unwrap(); + + let text = String::from_utf8(rsp.serialize_for_adapter()).unwrap(); + + assert!(text.starts_with("HTTP/1.1 404 Not Found\r\n")); + assert!(text.to_ascii_lowercase().contains("x-test: missing\r\n")); + assert!(text.ends_with("\r\n\r\n")); + } + + #[test] + fn serialize_uses_custom_reason_for_non_standard_status() { + let rsp = Response::builder() + .status(599) + .header("X-Test", "custom") + .body(()) + .unwrap(); + + let text = String::from_utf8(rsp.serialize_for_adapter()).unwrap(); + assert!(text.starts_with("HTTP/1.1 599 NOT STANDARD STATUS CODE\r\n")); + } + + #[tokio::test] + async fn adapt_to_replaces_status_and_headers() { + use tokio::io::BufReader; + use vey_http::client::HttpAdaptedResponse; + + let mut reader = BufReader::new(&b"HTTP/1.1 204 No Content\r\n\r\n"[..]); + let adapted = HttpAdaptedResponse::parse(&mut reader, 4096).await.unwrap(); + + let rsp = Response::builder() + .status(StatusCode::OK) + .header("X-Old", "1") + .body("payload") + .unwrap(); + + let adapted_rsp = rsp.adapt_to(&adapted); + assert_eq!(adapted_rsp.status(), StatusCode::NO_CONTENT); + assert!(adapted_rsp.headers().get("X-Old").is_none()); + } +} diff --git a/lib/vey-hickory-client/src/io/http/request.rs b/lib/vey-hickory-client/src/io/http/request.rs index 7c635b537..2e4e123fc 100644 --- a/lib/vey-hickory-client/src/io/http/request.rs +++ b/lib/vey-hickory-client/src/io/http/request.rs @@ -45,3 +45,51 @@ impl HttpDnsRequestBuilder { req } } + +#[cfg(test)] +mod tests { + use super::*; + use http::header; + + #[test] + fn new_builds_post_dns_query_request() { + let builder = HttpDnsRequestBuilder::new(Version::HTTP_11, "dns.example.com:443").unwrap(); + let req = builder.post(512); + + assert_eq!(req.method(), Method::POST); + assert_eq!(req.version(), Version::HTTP_11); + assert_eq!(req.uri().scheme().unwrap().as_str(), "https"); + assert_eq!( + req.uri().authority().unwrap().as_str(), + "dns.example.com:443" + ); + assert_eq!(req.uri().path(), "/dns-query"); + assert_eq!( + req.headers().get(header::CONTENT_TYPE).unwrap(), + "application/dns-message" + ); + assert_eq!( + req.headers().get(header::ACCEPT).unwrap(), + "application/dns-message" + ); + assert_eq!(req.headers().get(header::CONTENT_LENGTH).unwrap(), "512"); + } + + #[test] + fn new_rejects_invalid_authority() { + match HttpDnsRequestBuilder::new(Version::HTTP_11, "bad authority") { + Err(e) => assert!(e.to_string().contains("invalid authority")), + Ok(_) => panic!("expected invalid authority error"), + } + } + + #[test] + fn new_builds_http2_dns_query_request() { + let builder = HttpDnsRequestBuilder::new(Version::HTTP_2, "dns.google").unwrap(); + let req = builder.post(128); + + assert_eq!(req.version(), Version::HTTP_2); + assert_eq!(req.uri().authority().unwrap().host(), "dns.google"); + assert_eq!(req.headers().get(header::CONTENT_LENGTH).unwrap(), "128"); + } +} diff --git a/lib/vey-hickory-client/src/io/http/response.rs b/lib/vey-hickory-client/src/io/http/response.rs index 6e3ddceed..0ba98ae66 100644 --- a/lib/vey-hickory-client/src/io/http/response.rs +++ b/lib/vey-hickory-client/src/io/http/response.rs @@ -96,3 +96,102 @@ impl HttpDnsResponse { DnsResponse::from_buffer(self.body.to_vec()).map_err(NetError::Proto) } } + +#[cfg(test)] +mod tests { + use super::*; + use http::Response; + use http::StatusCode; + use http::header; + + const MIME_APPLICATION_DNS: &str = "application/dns-message"; + + fn dns_response(headers: http::HeaderMap) -> Response<()> { + let mut rsp = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, MIME_APPLICATION_DNS) + .body(()) + .unwrap(); + *rsp.headers_mut() = headers; + rsp + } + + #[test] + fn new_rejects_unsupported_content_type() { + let rsp = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain") + .body(()) + .unwrap(); + + match HttpDnsResponse::new(rsp) { + Err(e) => assert!(e.to_string().contains("unsupported ContentType")), + Ok(_) => panic!("expected unsupported content type error"), + } + } + + #[test] + fn body_end_respects_content_length() { + let mut headers = http::HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, http::HeaderValue::from_static("6")); + let mut rsp = HttpDnsResponse::new(dns_response(headers)).unwrap(); + assert!(!rsp.body_end()); + + rsp.push_body(&b"abc"[..]); + assert!(!rsp.body_end()); + + rsp.push_body(&b"def"[..]); + assert!(rsp.body_end()); + } + + #[test] + fn into_dns_response_rejects_length_mismatch() { + let mut headers = http::HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, http::HeaderValue::from_static("4")); + let mut rsp = HttpDnsResponse::new(dns_response(headers)).unwrap(); + rsp.push_body(&b"abc"[..]); + + match rsp.into_dns_response() { + Err(e) => assert!(e.to_string().contains("expected byte length: 4, got: 3")), + Ok(_) => panic!("expected length mismatch error"), + } + } + + #[test] + fn new_rejects_invalid_content_length() { + let mut headers = http::HeaderMap::new(); + headers.insert( + header::CONTENT_LENGTH, + http::HeaderValue::from_static("abc"), + ); + let rsp = dns_response(headers); + match HttpDnsResponse::new(rsp) { + Err(e) => assert!(e.to_string().contains("invalid Content-Length header")), + Ok(_) => panic!("expected invalid Content-Length error"), + } + } + + #[test] + fn into_dns_response_rejects_non_success_status() { + let mut rsp = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CONTENT_TYPE, MIME_APPLICATION_DNS) + .body(()) + .unwrap(); + rsp.headers_mut() + .insert(header::CONTENT_LENGTH, http::HeaderValue::from_static("5")); + let mut http_rsp = HttpDnsResponse::new(rsp).unwrap(); + http_rsp.push_body(&b"error"[..]); + + match http_rsp.into_dns_response() { + Err(e) => assert!(e.to_string().contains("http unsuccessful code: 502")), + Ok(_) => panic!("expected HTTP error"), + } + } + + #[test] + fn body_end_without_content_length_never_true() { + let rsp = HttpDnsResponse::new(dns_response(http::HeaderMap::new())).unwrap(); + assert!(!rsp.body_end()); + } +} diff --git a/lib/vey-histogram/src/lib.rs b/lib/vey-histogram/src/lib.rs index 75c9eeb84..2d5c6636a 100644 --- a/lib/vey-histogram/src/lib.rs +++ b/lib/vey-histogram/src/lib.rs @@ -20,3 +20,6 @@ pub use quantile::Quantile; mod config; pub use config::HistogramMetricsConfig; + +#[cfg(test)] +mod tests; diff --git a/lib/vey-histogram/src/tests.rs b/lib/vey-histogram/src/tests.rs new file mode 100644 index 000000000..064f11aed --- /dev/null +++ b/lib/vey-histogram/src/tests.rs @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. + */ + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use hdrhistogram::Histogram; + +use crate::{ + HistogramMetricsConfig, HistogramStats, KeepingHistogram, Quantile, RotatingHistogram, +}; + +#[test] +fn keeping_histogram_refresh_records() { + let (mut hist, recorder) = KeepingHistogram::new(); + recorder.record(42u64).unwrap(); + recorder.record(100u64).unwrap(); + hist.refresh().unwrap(); + + let inner = hist.inner(); + assert_eq!(inner.len(), 2); + assert_eq!(inner.min(), 42); + assert_eq!(inner.max(), 100); +} + +#[test] +fn histogram_stats_update_and_foreach() { + let mut hist = Histogram::::new(3).unwrap(); + for v in [10, 20, 30, 40, 50] { + hist.record(v).unwrap(); + } + + let stats = HistogramStats::with_quantiles(&[Quantile::PCT50, Quantile::PCT99]); + stats.update(&hist); + + let mut collected = Vec::new(); + stats.foreach_stat(|q, name, value| { + collected.push((q, name.to_string(), value)); + }); + + assert!(collected.iter().any(|(_, n, _)| n == "min")); + assert!(collected.iter().any(|(_, n, _)| n == "max")); + assert!(collected.iter().any(|(_, n, _)| n == "mean")); + assert!(collected.iter().any(|(q, _, _)| *q == Some(0.50))); + assert!(collected.iter().any(|(q, _, _)| *q == Some(0.99))); +} + +#[test] +fn histogram_metrics_config_defaults() { + let config = HistogramMetricsConfig::default(); + assert_eq!(config.rotate_interval(), Duration::from_secs(4)); +} + +#[test] +fn histogram_metrics_config_custom_quantiles() { + let mut config = HistogramMetricsConfig::with_rotate(Duration::from_secs(2)); + let mut quantiles = BTreeSet::new(); + quantiles.insert(Quantile::PCT95); + config.set_quantile_list(quantiles); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let (_recorder, stats) = config.build_spawned::(Some(rt.handle().clone())); + assert!(Arc::strong_count(&stats) >= 1); +} + +#[test] +fn rotating_histogram_recorder() { + let (_hist, recorder) = RotatingHistogram::::new(Duration::from_secs(1)); + recorder.record(7u64).unwrap(); + recorder.record(9u64).unwrap(); +} diff --git a/lib/vey-icap-client/src/options/response.rs b/lib/vey-icap-client/src/options/response.rs index f1bed0c5d..3ba8f9f6d 100644 --- a/lib/vey-icap-client/src/options/response.rs +++ b/lib/vey-icap-client/src/options/response.rs @@ -239,4 +239,45 @@ mod tests { assert!(options.expire.is_some()); assert!(!options.expired()); } + + #[test] + fn parse_header_allow_sets_support_flags() { + let mut options = IcapServiceOptions::new(IcapMethod::Reqmod); + options.parse_header_line(b"Allow: 204, 206\r\n").unwrap(); + assert!(options.support_204); + assert!(options.support_206); + } + + #[test] + fn parse_header_preview_sets_size() { + let mut options = IcapServiceOptions::new(IcapMethod::Reqmod); + options.parse_header_line(b"Preview: 4096\r\n").unwrap(); + assert_eq!(options.preview_size, Some(4096)); + } + + #[test] + fn parse_header_methods_must_match() { + let mut options = IcapServiceOptions::new(IcapMethod::Reqmod); + assert!(matches!( + options.parse_header_line(b"Methods: RESPMOD\r\n"), + Err(IcapOptionsParseError::MethodNotMatch) + )); + } + + #[test] + fn parse_header_encapsulated_accepts_null_and_opt_body() { + let mut options = IcapServiceOptions::new(IcapMethod::Reqmod); + options + .parse_header_line(b"Encapsulated: null-body=0, opt-body=42\r\n") + .unwrap(); + } + + #[test] + fn check_requires_istag() { + let options = IcapServiceOptions::new(IcapMethod::Reqmod); + assert!(matches!( + options.check(), + Err(IcapOptionsParseError::NoServiceTagSet) + )); + } } diff --git a/lib/vey-icap-client/src/parse/header_line.rs b/lib/vey-icap-client/src/parse/header_line.rs index f29659227..f650b0e79 100644 --- a/lib/vey-icap-client/src/parse/header_line.rs +++ b/lib/vey-icap-client/src/parse/header_line.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use super::IcapLineParseError; @@ -38,4 +39,27 @@ mod tests { assert_eq!(header.name, "测试"); assert_eq!(header.value, "结果"); } + + #[test] + fn rejects_missing_header_name() { + match HeaderLine::parse(b": value\r\n") { + Err(IcapLineParseError::MissingHeaderName) => {} + _ => panic!("expected MissingHeaderName"), + } + } + + #[test] + fn rejects_missing_delimiter() { + match HeaderLine::parse(b"Preview 1024\r\n") { + Err(IcapLineParseError::NoDelimiterFound(':')) => {} + _ => panic!("expected NoDelimiterFound(':')"), + } + } + + #[test] + fn trims_header_value() { + let header = HeaderLine::parse(b"Service: my-icap-server \r\n").unwrap(); + assert_eq!(header.name, "Service"); + assert_eq!(header.value, "my-icap-server"); + } } diff --git a/lib/vey-icap-client/src/parse/status_line.rs b/lib/vey-icap-client/src/parse/status_line.rs index a8fb90a26..90dee2817 100644 --- a/lib/vey-icap-client/src/parse/status_line.rs +++ b/lib/vey-icap-client/src/parse/status_line.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use atoi::FromRadix10; @@ -56,4 +57,47 @@ mod tests { assert_eq!(status.code, 200); assert_eq!(status.message, ""); } + + #[test] + fn rejects_invalid_version() { + match StatusLine::parse(b"HTTP/1.1 200 OK\r\n") { + Err(IcapLineParseError::InvalidIcapVersion) => {} + _ => panic!("expected InvalidIcapVersion"), + } + } + + #[test] + fn rejects_invalid_status_code() { + match StatusLine::parse(b"ICAP/1.0 99 Bad\r\n") { + Err(IcapLineParseError::InvalidStatusCode) => {} + _ => panic!("expected InvalidStatusCode"), + } + } + + #[test] + fn rejects_too_short_input() { + match StatusLine::parse(b"ICAP/1.0") { + Err(IcapLineParseError::NotLongEnough) => {} + _ => panic!("expected NotLongEnough"), + } + } + + #[test] + fn accepts_1xx_and_5xx_codes() { + let status = StatusLine::parse(b"ICAP/1.0 100 Continue\r\n").unwrap(); + assert_eq!(status.code, 100); + assert_eq!(status.message, "Continue"); + + let status = StatusLine::parse(b"ICAP/1.0 599 Custom\r\n").unwrap(); + assert_eq!(status.code, 599); + assert_eq!(status.message, "Custom"); + } + + #[test] + fn rejects_code_outside_valid_range() { + match StatusLine::parse(b"ICAP/1.0 600 Too High\r\n") { + Err(IcapLineParseError::InvalidStatusCode) => {} + _ => panic!("expected InvalidStatusCode"), + } + } } diff --git a/lib/vey-icap-client/src/reason.rs b/lib/vey-icap-client/src/reason.rs index a7b3cda0c..1c6138729 100644 --- a/lib/vey-icap-client/src/reason.rs +++ b/lib/vey-icap-client/src/reason.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::fmt; @@ -41,3 +42,32 @@ impl fmt::Display for IcapErrorReason { f.write_str(self.as_str()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_matches_as_str() { + let reason = IcapErrorReason::NoBodyFound; + assert_eq!(reason.to_string(), reason.as_str()); + assert_eq!(reason.to_string(), "no ICAP body found"); + } + + #[test] + fn all_variants_have_nonempty_display() { + let variants = [ + IcapErrorReason::InvalidResponse, + IcapErrorReason::UnknownResponse, + IcapErrorReason::InvalidResponseAfterContinue, + IcapErrorReason::UnknownResponseAfterContinue, + IcapErrorReason::ContinueAfterPreviewEof, + IcapErrorReason::UnknownResponseForPreview, + IcapErrorReason::NoBodyFound, + ]; + for reason in variants { + assert_eq!(reason.to_string(), reason.as_str()); + assert!(!reason.to_string().is_empty()); + } + } +} diff --git a/lib/vey-icap-client/src/serialize/header.rs b/lib/vey-icap-client/src/serialize/header.rs index e5df5672d..17d52d733 100644 --- a/lib/vey-icap-client/src/serialize/header.rs +++ b/lib/vey-icap-client/src/serialize/header.rs @@ -38,3 +38,46 @@ pub(crate) fn add_shared(buf: &mut Vec, headers: &HttpHeaderMap) { buf.put_slice(b"\r\n"); }); } + +#[cfg(test)] +mod tests { + use super::*; + use http::header::HeaderName; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use vey_types::net::{HttpHeaderMap, HttpHeaderValue}; + + #[test] + fn add_client_addr_serializes_ip_and_port() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8080); + let mut buf = Vec::new(); + add_client_addr(&mut buf, addr); + + let text = String::from_utf8(buf).unwrap(); + assert!(text.contains("X-Client-IP: 10.0.0.1\r\n")); + assert!(text.contains("X-Client-Port: 8080\r\n")); + } + + #[test] + fn add_client_username_url_encodes_and_base64_authenticated_user() { + let mut buf = Vec::new(); + add_client_username(&mut buf, "user@example"); + + let text = String::from_utf8(buf).unwrap(); + assert!(text.starts_with("X-Client-Username: ")); + assert!(text.contains("X-Authenticated-User: ")); + assert!(text.contains("user%40example") || text.contains("user@example")); + } + + #[test] + fn add_shared_copies_custom_headers() { + let mut headers = HttpHeaderMap::default(); + headers.append( + HeaderName::from_static("x-custom"), + HttpHeaderValue::from_static("alpha"), + ); + let mut buf = Vec::new(); + add_shared(&mut buf, &headers); + + assert_eq!(String::from_utf8(buf).unwrap(), "x-custom: alpha\r\n"); + } +} diff --git a/lib/vey-imap-proto/src/command/mod.rs b/lib/vey-imap-proto/src/command/mod.rs index 6a20da283..30079913c 100644 --- a/lib/vey-imap-proto/src/command/mod.rs +++ b/lib/vey-imap-proto/src/command/mod.rs @@ -311,4 +311,31 @@ mod tests { assert_eq!(cmd.parsed, ParsedCommand::Enable); assert!(cmd.literal_arg.is_none()); } + + #[test] + fn login() { + let cmd = Command::parse_line(b"A002 LOGIN user pass\r\n").unwrap(); + assert_eq!(cmd.parsed, ParsedCommand::Login); + } + + #[test] + fn noop() { + let cmd = Command::parse_line(b"A003 NOOP\r\n").unwrap(); + assert_eq!(cmd.parsed, ParsedCommand::NoOperation); + } + + #[test] + fn missing_crlf_rejected() { + assert!(Command::parse_line(b"A003 NOOP").is_err()); + } + + #[test] + fn missing_tag_rejected() { + assert!(Command::parse_line(b"NOOP\r\n").is_err()); + } + + #[test] + fn invalid_literal_size() { + assert!(Command::parse_line(b"A003 APPEND m () {abc}\r\n").is_err()); + } } diff --git a/lib/vey-imap-proto/src/pipeline.rs b/lib/vey-imap-proto/src/pipeline.rs index f6544b657..12b51f7ac 100644 --- a/lib/vey-imap-proto/src/pipeline.rs +++ b/lib/vey-imap-proto/src/pipeline.rs @@ -77,3 +77,56 @@ impl CommandPipeline { self.ongoing_response.take() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::{Command, ParsedCommand}; + use crate::response::{CommandData, UntaggedResponse}; + + fn tagged_command(tag: &str) -> Command { + Command { + tag: SmolStr::from(tag), + parsed: ParsedCommand::NoOperation, + literal_arg: None, + } + } + + fn empty_untagged() -> UntaggedResponse { + UntaggedResponse { + command_data: CommandData::Other, + literal_data: None, + } + } + + #[test] + fn insert_and_remove_completed() { + let mut pipeline = CommandPipeline::new(); + let old = pipeline.insert_completed(tagged_command("A001")); + assert!(old.is_none()); + assert!(pipeline.remove(&SmolStr::from("A001")).is_some()); + assert!(pipeline.remove(&SmolStr::from("A001")).is_none()); + } + + #[test] + fn remove_ongoing_command() { + let mut pipeline = CommandPipeline::new(); + pipeline.set_ongoing_command(tagged_command("B002")); + assert!(pipeline.remove(&SmolStr::from("B002")).is_some()); + + pipeline.set_ongoing_command(tagged_command("B003")); + assert!(pipeline.remove(&SmolStr::from("B002")).is_none()); + assert!(pipeline.ongoing_command().is_some()); + } + + #[test] + fn ongoing_response_lifecycle() { + let mut pipeline = CommandPipeline::new(); + assert!(pipeline.ongoing_response().is_none()); + + pipeline.set_ongoing_response(empty_untagged()); + assert!(pipeline.ongoing_response().is_some()); + assert!(pipeline.take_ongoing_response().is_some()); + assert!(pipeline.take_ongoing_response().is_none()); + } +} diff --git a/lib/vey-io-ext/src/haproxy/v1.rs b/lib/vey-io-ext/src/haproxy/v1.rs index aef9f1b0a..bc3ca144b 100644 --- a/lib/vey-io-ext/src/haproxy/v1.rs +++ b/lib/vey-io-ext/src/haproxy/v1.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -45,6 +46,8 @@ impl ProxyProtocolV1Reader { let family = iter .next() .ok_or(ProxyProtocolReadError::InvalidFamily(0x00))?; + // UNKNOWN lines end the family token with CRLF (no following fields). + let family = family.trim_ascii_end(); let family_c = match family.len() { 4 => { if !family.starts_with(b"TCP") { @@ -209,4 +212,48 @@ mod tests { assert!(result.is_err_and(|e| matches!(e, ProxyProtocolReadError::InvalidMagicHeader))); } + + #[test] + fn parse_unknown_line() { + let reader = ProxyProtocolV1Reader::new(Duration::from_secs(1)); + let result = reader.parse_buf(b"PROXY UNKNOWN\r\n"); + assert!(matches!(result, Ok(None))); + } + + #[test] + fn parse_tcp4_line() { + let reader = ProxyProtocolV1Reader::new(Duration::from_secs(1)); + let result = reader + .parse_buf(b"PROXY TCP4 192.168.1.2 10.0.0.1 54321 443\r\n") + .unwrap() + .unwrap(); + assert_eq!( + result.src_addr, + SocketAddr::from_str("192.168.1.2:54321").unwrap() + ); + assert_eq!( + result.dst_addr, + SocketAddr::from_str("10.0.0.1:443").unwrap() + ); + } + + #[test] + fn parse_rejects_invalid_family() { + let reader = ProxyProtocolV1Reader::new(Duration::from_secs(1)); + let result = reader.parse_buf(b"PROXY UDP4 1.1.1.1 2.2.2.2 80 443\r\n"); + assert!(matches!( + result, + Err(ProxyProtocolReadError::InvalidFamily(_)) + )); + } + + #[test] + fn parse_rejects_bad_src_port() { + let reader = ProxyProtocolV1Reader::new(Duration::from_secs(1)); + let result = reader.parse_buf(b"PROXY TCP4 192.168.1.1 10.0.0.1 abc 443\r\n"); + assert!(matches!( + result, + Err(ProxyProtocolReadError::InvalidSrcAddr) + )); + } } diff --git a/lib/vey-io-ext/src/haproxy/v2.rs b/lib/vey-io-ext/src/haproxy/v2.rs index 1b8219f11..4a08569fe 100644 --- a/lib/vey-io-ext/src/haproxy/v2.rs +++ b/lib/vey-io-ext/src/haproxy/v2.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -212,4 +213,59 @@ mod tests { run_t(client, server).await; } + + fn build_v2_local() -> Vec { + let mut buf = Vec::with_capacity(16); + buf.extend_from_slice(V2_MAGIC_HEADER); + buf.push(0x20); // version 2, LOCAL command + buf.push(0x00); // UNSPEC family/protocol + buf.extend_from_slice(&0u16.to_be_bytes()); + buf + } + + #[tokio::test] + async fn t_local_command_returns_none() { + let mut stream = tokio_test::io::Builder::new() + .read(&build_v2_local()) + .build(); + let mut reader = ProxyProtocolV2Reader::new(Duration::from_secs(1)); + let addr = reader + .read_proxy_protocol_v2_for_tcp(&mut stream) + .await + .unwrap(); + assert!(addr.is_none()); + } + + #[tokio::test] + async fn t_invalid_magic_header() { + let mut stream = tokio_test::io::Builder::new() + .read(b"not-a-proxy-hdr!") + .build(); + let mut reader = ProxyProtocolV2Reader::new(Duration::from_secs(1)); + let err = reader.read_proxy_protocol_v2_for_tcp(&mut stream).await; + assert!(matches!( + err, + Err(ProxyProtocolReadError::InvalidMagicHeader) + )); + } + + #[tokio::test] + async fn t_rejects_dgram_protocol() { + let mut encoder = ProxyProtocolEncoder::new(ProxyProtocolVersion::V2); + let client = SocketAddr::from_str("192.168.0.1:56324").unwrap(); + let server = SocketAddr::from_str("192.168.0.11:443").unwrap(); + let mut encoded = encoder.encode_tcp(client, server).unwrap().to_vec(); + // Flip protocol nibble to DGRAM (0x02) while keeping INET family. + encoded[13] = (encoded[13] & 0xF0) | 0x02; + + let mut stream = tokio_test::io::Builder::new() + .read(encoded.as_slice()) + .build(); + let mut reader = ProxyProtocolV2Reader::new(Duration::from_secs(1)); + let err = reader.read_proxy_protocol_v2_for_tcp(&mut stream).await; + assert!(matches!( + err, + Err(ProxyProtocolReadError::InvalidProtocol(2)) + )); + } } diff --git a/lib/vey-io-ext/src/limit/fixed_window/count.rs b/lib/vey-io-ext/src/limit/fixed_window/count.rs index 4016c1447..4ba2b3177 100644 --- a/lib/vey-io-ext/src/limit/fixed_window/count.rs +++ b/lib/vey-io-ext/src/limit/fixed_window/count.rs @@ -91,4 +91,17 @@ mod tests { assert!(limit_info.check(4).is_err()); assert!(limit_info.check(1025).is_ok()); } + + #[test] + fn threaded_limiter_resets_on_update() { + let limiter = ThreadedCountLimiter::new(10, 1); + assert!(limiter.check(1).is_ok()); + assert!(limiter.check(2).is_err()); + + let updated = limiter.new_updated(10, 3, 1024); + assert!(updated.check(1024).is_ok()); + assert!(updated.check(1025).is_ok()); + assert!(updated.check(1026).is_ok()); + assert!(updated.check(1027).is_err()); + } } diff --git a/lib/vey-io-ext/src/quic/limited_socket.rs b/lib/vey-io-ext/src/quic/limited_socket.rs index 8c0445b2b..3b23f478b 100644 --- a/lib/vey-io-ext/src/quic/limited_socket.rs +++ b/lib/vey-io-ext/src/quic/limited_socket.rs @@ -203,12 +203,29 @@ impl UdpPoller for LimitedUdpPoller { } } +/// Rate-limited wrapper around a Quinn [`AsyncUdpSocket`]. +/// +/// # Concurrency +/// +/// Quinn requires `AsyncUdpSocket: Sync` so the socket can be shared via `Arc` +/// across connection tasks that call [`AsyncUdpSocket::try_send`]. Send-side +/// limiting uses a [`Mutex`]; receive-side state (`recv_state`) is mutated +/// through [`UnsafeCell`] inside [`AsyncUdpSocket::poll_recv`], which is **not** +/// safe to call concurrently. +/// +/// This type must only be used with a Quinn [`quinn::Endpoint`]: Quinn drives +/// UDP receive from a single `EndpointDriver` task (serialized under the +/// endpoint state mutex), so `poll_recv` is never invoked concurrently. +/// Calling `poll_recv` from multiple tasks yourself is undefined behavior. pub struct LimitedUdpSocket { inner: Arc, send_state: LimitedSendState, recv_state: UnsafeCell, } +// SAFETY: See type-level docs. Sync is required by Quinn's AsyncUdpSocket; +// exclusive access to recv_state is guaranteed by Quinn's single +// EndpointDriver receive path (no concurrent poll_recv). unsafe impl Sync for LimitedUdpSocket {} impl LimitedUdpSocket { @@ -326,6 +343,8 @@ impl AsyncUdpSocket for LimitedUdpSocket { bufs: &mut [IoSliceMut<'_>], meta: &mut [udp::RecvMeta], ) -> Poll> { + // SAFETY: poll_recv is only driven by Quinn's single EndpointDriver + // (see type-level docs); no concurrent &mut to recv_state. let l = unsafe { &mut *self.recv_state.get() }; if l.limit.is_set() { let dur_millis = l.started.elapsed().as_millis() as u64; diff --git a/lib/vey-io-ext/src/stream/buf/flex.rs b/lib/vey-io-ext/src/stream/buf/flex.rs index 0a99819c8..5161c5be7 100644 --- a/lib/vey-io-ext/src/stream/buf/flex.rs +++ b/lib/vey-io-ext/src/stream/buf/flex.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -220,7 +221,7 @@ impl From> for OnceBufReader { mod tests { use super::FlexBufReader; use bytes::{BufMut, BytesMut}; - use tokio::io::AsyncBufReadExt; + use tokio::io::{AsyncBufReadExt, AsyncReadExt}; #[tokio::test] async fn with_bytes() { @@ -240,4 +241,20 @@ mod tests { let buf = v.fill_buf().await.unwrap(); assert_eq!(buf, content); } + + #[tokio::test] + async fn into_parts_and_from_once_buf_reader() { + let mut b = BytesMut::with_capacity(8); + b.put_slice(b"pref"); + let stream = tokio_test::io::Builder::new().read(b"rest").build(); + let reader = FlexBufReader::with_bytes(b, stream); + + let (buf, inner) = reader.into_parts(); + assert_eq!(&buf[..], b"pref"); + + let mut restored = FlexBufReader::with_bytes(BytesMut::from(buf), inner); + let mut out = Vec::new(); + restored.read_to_end(&mut out).await.unwrap(); + assert_eq!(out, b"prefrest"); + } } diff --git a/lib/vey-io-ext/src/stream/buf/once.rs b/lib/vey-io-ext/src/stream/buf/once.rs index 3e15c9e4b..2528e76ed 100644 --- a/lib/vey-io-ext/src/stream/buf/once.rs +++ b/lib/vey-io-ext/src/stream/buf/once.rs @@ -137,3 +137,28 @@ where ) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + use tokio::io::AsyncReadExt; + + #[tokio::test] + async fn serves_prefixed_bytes_before_inner_reader() { + let inner = tokio_test::io::Builder::new().read(b"tail").build(); + let reader = OnceBufReader::new(inner, BytesMut::from(&b"head"[..])); + + let mut out = Vec::new(); + let mut reader = reader; + reader.read_to_end(&mut out).await.unwrap(); + assert_eq!(out, b"headtail"); + } + + #[test] + fn empty_initial_buffer_delegates_to_inner() { + let mut reader = OnceBufReader::with_no_buf(()); + assert!(reader.buf().is_none()); + assert!(reader.take_buf().is_none()); + } +} diff --git a/lib/vey-io-ext/src/stream/ext/fill_wait_data.rs b/lib/vey-io-ext/src/stream/ext/fill_wait_data.rs index b305b8cd4..6848f268c 100644 --- a/lib/vey-io-ext/src/stream/ext/fill_wait_data.rs +++ b/lib/vey-io-ext/src/stream/ext/fill_wait_data.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -42,3 +43,23 @@ impl Future for FillWaitData<'_, R> { fill_wait_data(Pin::new(reader), cx) } } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::BufReader; + + #[tokio::test] + async fn returns_true_when_data_available() { + let stream = tokio_test::io::Builder::new().read(b"data").build(); + let mut reader = BufReader::new(stream); + assert!(FillWaitData::new(&mut reader).await.unwrap()); + } + + #[tokio::test] + async fn returns_false_on_immediate_eof() { + let stream = tokio_test::io::Builder::new().read(&[]).build(); + let mut reader = BufReader::new(stream); + assert!(!FillWaitData::new(&mut reader).await.unwrap()); + } +} diff --git a/lib/vey-io-ext/src/stream/ext/limited_skip_until.rs b/lib/vey-io-ext/src/stream/ext/limited_skip_until.rs index 278ff2b8d..f06634f00 100644 --- a/lib/vey-io-ext/src/stream/ext/limited_skip_until.rs +++ b/lib/vey-io-ext/src/stream/ext/limited_skip_until.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -75,3 +76,48 @@ impl Future for LimitedSkipUntil<'_, R> { skip_until_internal(Pin::new(reader), cx, *delimiter, read, *limit) } } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::BufReader; + + #[tokio::test] + async fn skip_until_delimiter() { + let content = b"junk prefix\r\nrest"; + let stream = tokio_test::io::Builder::new().read(content).build(); + let mut buf_stream = BufReader::new(stream); + + let (found, size) = LimitedSkipUntil::new(&mut buf_stream, b'\n', 128) + .await + .unwrap(); + assert!(found); + assert_eq!(size, 13); + } + + #[tokio::test] + async fn skip_until_exceeds_limit() { + let content = b"0123456789abcdef\n"; + let stream = tokio_test::io::Builder::new().read(content).build(); + let mut buf_stream = BufReader::new(stream); + + let (found, size) = LimitedSkipUntil::new(&mut buf_stream, b'\n', 8) + .await + .unwrap(); + assert!(!found); + assert!(size >= 8); + } + + #[tokio::test] + async fn skip_until_eof_without_delimiter() { + let content = b"no newline here"; + let stream = tokio_test::io::Builder::new().read(content).build(); + let mut buf_stream = BufReader::new(stream); + + let (found, size) = LimitedSkipUntil::new(&mut buf_stream, b'\n', 128) + .await + .unwrap(); + assert!(!found); + assert_eq!(size, content.len()); + } +} diff --git a/lib/vey-io-ext/src/stream/ext/read_all_once.rs b/lib/vey-io-ext/src/stream/ext/read_all_once.rs index 3357bcbf8..f86ccdd4b 100644 --- a/lib/vey-io-ext/src/stream/ext/read_all_once.rs +++ b/lib/vey-io-ext/src/stream/ext/read_all_once.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::future::Future; @@ -70,3 +71,37 @@ where read_all_once_internal(Pin::new(reader), cx, buf) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn read_fills_buffer_once() { + let mut stream = tokio_test::io::Builder::new().read(&b"abcd"[..]).build(); + let mut buf = [0u8; 4]; + let nr = ReadAllOnce::new(&mut stream, &mut buf).await.unwrap(); + assert_eq!(nr, 4); + assert_eq!(&buf, b"abcd"); + } + + #[tokio::test] + async fn read_returns_zero_on_closed_stream() { + let mut stream = tokio_test::io::Builder::new().read(&[]).build(); + let mut buf = [0u8; 8]; + let nr = ReadAllOnce::new(&mut stream, &mut buf).await.unwrap(); + assert_eq!(nr, 0); + } + + #[tokio::test] + async fn read_stops_after_first_pending_with_partial_data() { + let mut stream = tokio_test::io::Builder::new() + .read(b"ab") + .wait(std::time::Duration::from_secs(60)) + .build(); + let mut buf = [0u8; 8]; + let nr = ReadAllOnce::new(&mut stream, &mut buf).await.unwrap(); + assert_eq!(nr, 2); + assert_eq!(&buf[..2], b"ab"); + } +} diff --git a/lib/vey-io-ext/src/time/idle.rs b/lib/vey-io-ext/src/time/idle.rs index 1d73ca2aa..5d87a620c 100644 --- a/lib/vey-io-ext/src/time/idle.rs +++ b/lib/vey-io-ext/src/time/idle.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::sync::Arc; @@ -50,3 +51,45 @@ pub trait IdleCheck { fn check_quit(&self, idle_count: usize) -> bool; fn check_force_quit(&self) -> Option; } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn idle_wheel_tick_advances() { + let wheel = IdleWheel::spawn(Duration::from_millis(5)); + let mut interval = wheel.register(); + + assert_eq!(interval.period(), Duration::from_millis(5)); + + let tick = tokio::time::timeout(Duration::from_millis(50), interval.tick()).await; + assert!(tick.is_ok()); + assert_eq!(tick.unwrap(), 1); + } + + #[tokio::test] + async fn idle_wheel_multiple_ticks() { + let wheel = IdleWheel::spawn(Duration::from_millis(5)); + let mut interval = wheel.register(); + + for _ in 0..3 { + let tick = tokio::time::timeout(Duration::from_millis(50), interval.tick()).await; + assert!(tick.is_ok()); + assert_eq!(tick.unwrap(), 1); + } + } + + #[test] + fn idle_force_quit_reason_debug() { + assert_eq!( + format!("{:?}", IdleForceQuitReason::UserBlocked), + "UserBlocked" + ); + assert_eq!( + format!("{:?}", IdleForceQuitReason::ServerQuit), + "ServerQuit" + ); + } +} diff --git a/lib/vey-io-sys/src/udp/cmsg/mod.rs b/lib/vey-io-sys/src/udp/cmsg/mod.rs index fd1af8f68..398c1c477 100644 --- a/lib/vey-io-sys/src/udp/cmsg/mod.rs +++ b/lib/vey-io-sys/src/udp/cmsg/mod.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -45,3 +46,50 @@ impl RecvAncillaryBuffer { Self::parse_buf(&self.buf[..total_size], data) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ancillary_buffer_has_expected_capacity() { + let buf = RecvAncillaryBuffer::new(); + assert_eq!(buf.as_bytes().len(), CMSG_RECV_BUFFER_SIZE); + assert_eq!(CMSG_RECV_BUFFER_SIZE, 10240); + } + + #[test] + fn parse_empty_control_buffer_is_ok() { + struct Noop; + impl RecvAncillaryData for Noop { + fn set_recv_interface(&mut self, _id: u32) {} + fn set_recv_dst_addr(&mut self, _addr: IpAddr) {} + fn set_timestamp(&mut self, _ts: Duration) {} + } + + let mut data = Noop; + RecvAncillaryBuffer::parse_buf(&[], &mut data).unwrap(); + } + + #[test] + fn default_matches_new() { + assert_eq!( + RecvAncillaryBuffer::default().as_bytes().len(), + RecvAncillaryBuffer::new().as_bytes().len() + ); + } + + #[test] + fn parse_buf_rejects_truncated_input() { + struct Noop; + impl RecvAncillaryData for Noop { + fn set_recv_interface(&mut self, _id: u32) {} + fn set_recv_dst_addr(&mut self, _addr: IpAddr) {} + fn set_timestamp(&mut self, _ts: Duration) {} + } + + // Random non-empty garbage should not panic; platform parser may error. + let mut data = Noop; + let _ = RecvAncillaryBuffer::parse_buf(&[0xFF, 0x01, 0x02], &mut data); + } +} diff --git a/lib/vey-ip-locate/src/request.rs b/lib/vey-ip-locate/src/request.rs index 3e861b2a8..b68fa1283 100644 --- a/lib/vey-ip-locate/src/request.rs +++ b/lib/vey-ip-locate/src/request.rs @@ -80,3 +80,56 @@ impl Request { Ok(buf) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn encode_and_parse_single_ip() { + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + let encoded = Request::encode_new(ip).unwrap(); + let parsed = Request::parse_req(&encoded).unwrap(); + assert_eq!(parsed.ip(), Some(ip)); + } + + #[test] + fn parse_map_with_string_key() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)); + let ip_str = ip.to_string(); + let map = rmpv::ValueRef::Map(vec![( + rmpv::ValueRef::String("ip".into()), + rmpv::ValueRef::String(ip_str.as_str().into()), + )]); + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &map).unwrap(); + let parsed = Request::parse_req(&buf).unwrap(); + assert_eq!(parsed.ip(), Some(ip)); + } + + #[test] + fn parse_map_with_integer_key() { + let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)); + let ip_str = ip.to_string(); + let map = rmpv::ValueRef::Map(vec![( + rmpv::ValueRef::Integer(request_key_id::IP.into()), + rmpv::ValueRef::String(ip_str.as_str().into()), + )]); + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &map).unwrap(); + let parsed = Request::parse_req(&buf).unwrap(); + assert_eq!(parsed.ip(), Some(ip)); + } + + #[test] + fn parse_invalid_key_rejected() { + let map = rmpv::ValueRef::Map(vec![( + rmpv::ValueRef::String("unknown".into()), + rmpv::ValueRef::String("1.2.3.4".into()), + )]); + let mut buf = Vec::new(); + rmpv::encode::write_value_ref(&mut buf, &map).unwrap(); + assert!(Request::parse_req(&buf).is_err()); + } +} diff --git a/lib/vey-ip-locate/src/response.rs b/lib/vey-ip-locate/src/response.rs index ddb63ffe3..0c4edaf0c 100644 --- a/lib/vey-ip-locate/src/response.rs +++ b/lib/vey-ip-locate/src/response.rs @@ -193,3 +193,69 @@ impl Response { Ok(buf) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + use vey_geoip_types::{ContinentCode, IsoCountryCode}; + + fn parsed_location() -> IpLocation { + let map = rmpv::ValueRef::Map(vec![ + ( + rmpv::ValueRef::Integer(response_key_id::NETWORK.into()), + rmpv::ValueRef::String("203.0.113.0/24".into()), + ), + ( + rmpv::ValueRef::Integer(response_key_id::COUNTRY.into()), + rmpv::ValueRef::String("US".into()), + ), + ( + rmpv::ValueRef::Integer(response_key_id::CONTINENT.into()), + rmpv::ValueRef::String("NA".into()), + ), + ( + rmpv::ValueRef::Integer(response_key_id::AS_NUMBER.into()), + rmpv::ValueRef::Integer(64512.into()), + ), + ( + rmpv::ValueRef::Integer(response_key_id::ISP_NAME.into()), + rmpv::ValueRef::String("Example ISP".into()), + ), + ( + rmpv::ValueRef::Integer(response_key_id::ISP_DOMAIN.into()), + rmpv::ValueRef::String("example.net".into()), + ), + ]); + let parsed = Response::parse(map).unwrap(); + parsed.into_parts().1.unwrap() + } + + #[test] + fn encode_decode_roundtrip() { + let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 42)); + let location = parsed_location(); + let ttl = 300u32; + + let encoded = Response::encode_new(ip, location, ttl).unwrap(); + let mut data = encoded.as_slice(); + let value = rmpv::decode::read_value_ref(&mut data).unwrap(); + let parsed = Response::parse(value).unwrap(); + let (parsed_ip, parsed_location, parsed_ttl) = parsed.into_parts(); + + assert_eq!(parsed_ip, Some(ip)); + assert_eq!(parsed_ttl, Some(ttl)); + let loc = parsed_location.unwrap(); + assert_eq!(loc.country(), Some(IsoCountryCode::US)); + assert_eq!(loc.continent(), Some(ContinentCode::NA)); + assert_eq!(loc.network_asn(), Some(64512)); + assert_eq!(loc.isp_name(), Some("Example ISP")); + assert_eq!(loc.isp_domain(), Some("example.net")); + } + + #[test] + fn parse_rejects_non_map() { + assert!(Response::parse(rmpv::ValueRef::String("not-a-map".into())).is_err()); + } +} diff --git a/lib/vey-jemalloc/src/lib.rs b/lib/vey-jemalloc/src/lib.rs index a6eab708f..ee5c8be2f 100644 --- a/lib/vey-jemalloc/src/lib.rs +++ b/lib/vey-jemalloc/src/lib.rs @@ -36,3 +36,51 @@ unsafe impl GlobalAlloc for Jemalloc { unsafe { jemalloc_sys::rallocx(ptr as _, new_size, align_flags(layout)) as _ } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::alloc::{GlobalAlloc, Layout}; + + #[test] + fn global_alloc_smoke() { + let layout = Layout::from_size_align(128, 16).unwrap(); + let ptr = unsafe { Jemalloc.alloc(layout) }; + assert!(!ptr.is_null()); + unsafe { Jemalloc.dealloc(ptr, layout) }; + } + + #[test] + fn global_alloc_zeroed_smoke() { + let layout = Layout::from_size_align(64, 8).unwrap(); + let ptr = unsafe { Jemalloc.alloc_zeroed(layout) }; + assert!(!ptr.is_null()); + let slice = unsafe { std::slice::from_raw_parts(ptr, layout.size()) }; + assert!(slice.iter().all(|b| *b == 0)); + unsafe { Jemalloc.dealloc(ptr, layout) }; + } + + #[test] + fn global_alloc_realloc_smoke() { + let layout = Layout::from_size_align(32, 8).unwrap(); + let ptr = unsafe { Jemalloc.alloc(layout) }; + assert!(!ptr.is_null()); + let new_ptr = unsafe { Jemalloc.realloc(ptr, layout, 64) }; + assert!(!new_ptr.is_null()); + unsafe { Jemalloc.dealloc(new_ptr, Layout::from_size_align(64, 8).unwrap()) }; + } + + #[test] + fn lib_version_returns_jemalloc_version() { + let version = lib_version().expect("jemalloc version should be available"); + let s = version.to_str().expect("version should be utf-8"); + assert!(!s.is_empty()); + } + + #[test] + fn stats_entries_read_values() { + let allocated = stats::allocated().expect("stats.allocated mib"); + assert_eq!(allocated.name().to_str().unwrap(), "stats.allocated"); + assert!(allocated.value().is_some()); + } +} diff --git a/lib/vey-journal/src/format.rs b/lib/vey-journal/src/format.rs index d95feb705..eec4092d0 100644 --- a/lib/vey-journal/src/format.rs +++ b/lib/vey-journal/src/format.rs @@ -301,4 +301,12 @@ mod tests { .unwrap(); assert_eq!(vars, b"A_KEY\n\x07\0\0\0\0\0\0\0a-v1\nv2\n"); } + + #[test] + fn priority_mapping() { + let mut vars = Vec::new(); + let mut kv_formatter = FormatterKv(&mut vars); + kv_formatter.emit_sanitized_one_line("PRIORITY", level_to_sd_priority(Level::Error)); + assert_eq!(vars, b"PRIORITY=3\n"); + } } diff --git a/lib/vey-macros/Cargo.toml b/lib/vey-macros/Cargo.toml index 5f2ee8aa7..cc307e760 100644 --- a/lib/vey-macros/Cargo.toml +++ b/lib/vey-macros/Cargo.toml @@ -11,3 +11,6 @@ proc-macro = true proc-macro2 = "1.0" syn = { version = "3.0", features = ["full"] } quote = "1.0" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/lib/vey-macros/tests/any_config.rs b/lib/vey-macros/tests/any_config.rs index 4b0e721c4..2005a58fd 100644 --- a/lib/vey-macros/tests/any_config.rs +++ b/lib/vey-macros/tests/any_config.rs @@ -60,3 +60,78 @@ fn test_any() { let any_config2 = AnyTestConfig::Variant2(ConfigA {}); assert!(!any_config.same_as(&any_config2)); } + +#[test] +fn test_async_run() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let any_config = AnyTestConfig::Variant1(ConfigA {}); + any_config.run().await; + }); +} + +struct ConfigB { + label: String, +} + +impl ConfigB { + fn name(&self) -> &str { + &self.label + } + + fn version(&self) -> usize { + 2 + } + + fn same_as(&self, other: &AnyTestConfigWithParams) -> bool { + match other { + AnyTestConfigWithParams::Left(c) | AnyTestConfigWithParams::Right(c) => { + c.label == self.label + } + } + } + + fn reload(&self) {} + + async fn run(&self) {} +} + +#[derive(AnyConfig)] +#[def_fn(name, &str)] +#[def_fn(version, usize)] +#[def_fn(same_as, &AnyTestConfigWithParams, bool)] +#[def_fn(reload)] +#[def_async_fn(run)] +pub(crate) enum AnyTestConfigWithParams { + Left(ConfigB), + Right(ConfigB), +} + +#[test] +fn test_param_same_as() { + let a = AnyTestConfigWithParams::Left(ConfigB { + label: "B".to_string(), + }); + let b = AnyTestConfigWithParams::Right(ConfigB { + label: "B".to_string(), + }); + let c = AnyTestConfigWithParams::Right(ConfigB { + label: "C".to_string(), + }); + assert_eq!(a.name(), "B"); + assert_eq!(a.version(), 2); + a.reload(); + assert!(a.same_as(&b)); + assert!(!a.same_as(&c)); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + a.run().await; + }); +} diff --git a/lib/vey-mimalloc/src/lib.rs b/lib/vey-mimalloc/src/lib.rs index 683d4d3ef..d88cda2b4 100644 --- a/lib/vey-mimalloc/src/lib.rs +++ b/lib/vey-mimalloc/src/lib.rs @@ -29,3 +29,49 @@ unsafe impl GlobalAlloc for Mimalloc { unsafe { mimalloc_sys::mi_realloc_aligned(ptr as _, new_size, layout.align()) as _ } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::alloc::{GlobalAlloc, Layout}; + + #[test] + fn global_alloc_smoke() { + let layout = Layout::from_size_align(128, 16).unwrap(); + let ptr = unsafe { Mimalloc.alloc(layout) }; + assert!(!ptr.is_null()); + unsafe { Mimalloc.dealloc(ptr, layout) }; + } + + #[test] + fn global_alloc_zeroed_smoke() { + let layout = Layout::from_size_align(64, 8).unwrap(); + let ptr = unsafe { Mimalloc.alloc_zeroed(layout) }; + assert!(!ptr.is_null()); + let slice = unsafe { std::slice::from_raw_parts(ptr, layout.size()) }; + assert!(slice.iter().all(|b| *b == 0)); + unsafe { Mimalloc.dealloc(ptr, layout) }; + } + + #[test] + fn global_alloc_realloc_smoke() { + let layout = Layout::from_size_align(32, 8).unwrap(); + let ptr = unsafe { Mimalloc.alloc(layout) }; + assert!(!ptr.is_null()); + let new_ptr = unsafe { Mimalloc.realloc(ptr, layout, 64) }; + assert!(!new_ptr.is_null()); + unsafe { Mimalloc.dealloc(new_ptr, Layout::from_size_align(64, 8).unwrap()) }; + } + + #[test] + fn lib_version_is_positive() { + assert!(lib_version() > 0); + } + + #[test] + fn process_stats_available() { + let stats = stats::get().expect("mimalloc stats should be readable"); + assert!(stats.current_pages >= 0); + assert!(stats.current_commit >= 0); + } +} diff --git a/lib/vey-openssl/src/async_job/task.rs b/lib/vey-openssl/src/async_job/task.rs index 594c41fa2..0462540bb 100644 --- a/lib/vey-openssl/src/async_job/task.rs +++ b/lib/vey-openssl/src/async_job/task.rs @@ -74,6 +74,22 @@ struct TaskState { action: Box>>, } +/// Future that drives an OpenSSL `ASYNC_JOB` to completion (with an internal timeout). +/// +/// # Concurrency / threading +/// +/// OpenSSL requires that a paused `ASYNC_JOB` be resumed via `ASYNC_start_job` on +/// **the same OS thread** that started it. Job pools are also per-thread +/// (`ASYNC_init_thread` / `ASYNC_cleanup_thread`). +/// +/// Therefore this future must: +/// - be created and polled only on a Tokio **current-thread** runtime (see +/// [`TokioAsyncOperation::build_async_task`](crate::async_job::TokioAsyncOperation::build_async_task)); +/// - never migrate to another thread while a job is in flight. +/// +/// Prefer constructing via [`TokioAsyncOperation::build_async_task`], which asserts +/// `RuntimeFlavor::CurrentThread`. Polling from a multi-thread runtime or after +/// thread migration is undefined behavior with respect to OpenSSL. pub struct OpensslAsyncTask { state: Option>, sleep_future: Pin>, @@ -81,13 +97,16 @@ pub struct OpensslAsyncTask { /// Drains an in-flight OpenSSL `ASYNC_JOB` after a timeout. /// -/// Must be polled on the same current-thread runtime that started the job. +/// Must be polled on the **same** current-thread runtime (same OS thread) that +/// started the job. See [`OpensslAsyncTask`] concurrency notes. pub struct OpensslAsyncCleanup { state: TaskState, } -/// NOTE: OpensslAsyncTask in fact is not Send, -/// make sure you call it in a single threaded async runtime +// SAFETY: OpenSSL ASYNC_JOBs are thread-affine. These types are not logically +// Send for in-flight jobs; `Send` exists so they can appear in async signatures +// that require it. Callers must keep polling on the creating current-thread +// runtime (enforced for the usual path by TokioAsyncOperation::build_async_task). unsafe impl Send for OpensslAsyncTask {} unsafe impl Send for OpensslAsyncCleanup {} diff --git a/lib/vey-openssl/src/async_job/tokio_op.rs b/lib/vey-openssl/src/async_job/tokio_op.rs index c3ed3345b..f4c06cd45 100644 --- a/lib/vey-openssl/src/async_job/tokio_op.rs +++ b/lib/vey-openssl/src/async_job/tokio_op.rs @@ -16,6 +16,10 @@ use tokio::runtime::{Handle, RuntimeFlavor}; use super::{AsyncOperation, OpensslAsyncTask, SyncOperation}; +/// Tokio-backed OpenSSL async operation (FD wait via [`AsyncFd`]). +/// +/// Construct only through [`Self::build_async_task`], which requires a +/// `current_thread` runtime. See [`OpensslAsyncTask`] for the threading contract. pub struct TokioAsyncOperation { sync_op: T, tracked_fds: Vec>, @@ -25,21 +29,25 @@ impl TokioAsyncOperation where T: SyncOperation, { - /// Create a openssl async task in tokio single threaded runtime + /// Build an [`OpensslAsyncTask`] on the current Tokio **current-thread** runtime. /// /// `timeout` is enforced inside the task: after it elapses the future returns /// [`OpensslAsyncOutput::TimedOut`](super::OpensslAsyncOutput::TimedOut) with /// an optional cleanup handle to drain any in-flight `ASYNC_JOB`. /// Do not wrap the returned future in `tokio::time::timeout`. /// - /// It will panic if called in multi-threaded runtime + /// # Panics + /// + /// Panics if the current runtime is multi-threaded. OpenSSL `ASYNC_JOB`s are + /// thread-affine and must not migrate across worker threads. pub fn build_async_task( sync_op: T, timeout: Duration, ) -> Result>, ErrorStack> { assert_eq!( Handle::current().runtime_flavor(), - RuntimeFlavor::CurrentThread + RuntimeFlavor::CurrentThread, + "OpensslAsyncTask requires a current_thread Tokio runtime" ); let async_op = TokioAsyncOperation { diff --git a/lib/vey-openssl/src/ssl/error.rs b/lib/vey-openssl/src/ssl/error.rs index e5de28478..10e0c815e 100644 --- a/lib/vey-openssl/src/ssl/error.rs +++ b/lib/vey-openssl/src/ssl/error.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::error::Error; @@ -59,3 +60,18 @@ impl Error for SslError { self.inner.source() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_as_str_covers_all_variants() { + assert_eq!(SslErrorAction::Accept.as_str(), "accept"); + assert_eq!(SslErrorAction::Connect.as_str(), "connect"); + assert_eq!(SslErrorAction::Read.as_str(), "read"); + assert_eq!(SslErrorAction::Peek.as_str(), "peek"); + assert_eq!(SslErrorAction::Write.as_str(), "write"); + assert_eq!(SslErrorAction::Shutdown.as_str(), "shutdown"); + } +} diff --git a/lib/vey-openssl/src/ssl/types/mask.rs b/lib/vey-openssl/src/ssl/types/mask.rs index e3079b016..8df23df67 100644 --- a/lib/vey-openssl/src/ssl/types/mask.rs +++ b/lib/vey-openssl/src/ssl/types/mask.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use bitflags::bitflags; @@ -16,3 +17,37 @@ bitflags! { const ALERT = 0x4000; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contains_expected_flags() { + let flags = SslInfoCallbackWhere::READ | SslInfoCallbackWhere::WRITE; + assert!(flags.contains(SslInfoCallbackWhere::READ)); + assert!(flags.contains(SslInfoCallbackWhere::WRITE)); + assert!(!flags.contains(SslInfoCallbackWhere::HANDSHAKE_DONE)); + } + + #[test] + fn handshake_flags_have_expected_bits() { + assert_eq!(SslInfoCallbackWhere::HANDSHAKE_START.bits(), 0x10); + assert_eq!(SslInfoCallbackWhere::HANDSHAKE_DONE.bits(), 0x20); + assert_eq!(SslInfoCallbackWhere::ALERT.bits(), 0x4000); + } + + #[test] + fn all_flags_combine_without_overlap() { + let all = SslInfoCallbackWhere::LOOP + | SslInfoCallbackWhere::EXIT + | SslInfoCallbackWhere::READ + | SslInfoCallbackWhere::WRITE + | SslInfoCallbackWhere::HANDSHAKE_START + | SslInfoCallbackWhere::HANDSHAKE_DONE + | SslInfoCallbackWhere::ALERT; + assert!(all.contains(SslInfoCallbackWhere::LOOP)); + assert!(all.contains(SslInfoCallbackWhere::ALERT)); + assert_eq!(all.bits(), 0x403F); + } +} diff --git a/lib/vey-redis-client/Cargo.toml b/lib/vey-redis-client/Cargo.toml index 540f9509b..0a4e926d3 100644 --- a/lib/vey-redis-client/Cargo.toml +++ b/lib/vey-redis-client/Cargo.toml @@ -19,3 +19,6 @@ vey-yaml = { workspace = true, optional = true, features = ["rustls"] } [features] default = [] yaml = ["dep:vey-yaml", "dep:yaml-rust"] + +[dev-dependencies] +rustls = { workspace = true, features = ["ring"] } diff --git a/lib/vey-redis-client/src/lib.rs b/lib/vey-redis-client/src/lib.rs index 3306bce46..6c182b468 100644 --- a/lib/vey-redis-client/src/lib.rs +++ b/lib/vey-redis-client/src/lib.rs @@ -200,3 +200,86 @@ impl RedisClientConfig { Ok(conn) } } + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + use std::str::FromStr; + use std::sync::Once; + use std::time::Duration; + + use rustls_pki_types::ServerName; + use vey_types::net::{Host, RustlsClientConfigBuilder, UpstreamAddr}; + + use super::*; + + fn init_test_rustls_provider() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + rustls::crypto::ring::default_provider() + .install_default() + .expect("test rustls ring provider should install once"); + }); + } + + #[test] + fn default_builder_uses_localhost_and_redis_port() { + let builder = RedisClientConfigBuilder::default(); + assert_eq!( + builder.addr, + UpstreamAddr::new( + Host::Ip(IpAddr::from_str("127.0.0.1").unwrap()), + REDIS_DEFAULT_PORT, + ) + ); + assert_eq!(builder.connect_timeout, Duration::from_secs(5)); + assert_eq!(builder.response_timeout, Duration::from_secs(2)); + assert_eq!(builder.db, 0); + assert!(builder.tls_client.is_none()); + } + + #[test] + fn build_plain_config_sets_resp3_and_credentials() { + let mut builder = RedisClientConfigBuilder::default(); + builder.set_db(3); + builder.set_username("user".to_string()); + builder.set_password("secret".to_string()); + builder.set_connect_timeout(Duration::from_secs(11)); + builder.set_response_timeout(Duration::from_secs(4)); + + let config = builder.build().unwrap(); + assert_eq!(config.server.port(), REDIS_DEFAULT_PORT); + assert_eq!(config.connect_timeout, Duration::from_secs(11)); + assert_eq!(config.response_timeout, Duration::from_secs(4)); + assert!(config.tls_client.is_none()); + } + + #[test] + fn build_tls_config_uses_explicit_server_name() { + init_test_rustls_provider(); + let mut builder = RedisClientConfigBuilder::default(); + builder.set_tls_client(RustlsClientConfigBuilder::default()); + builder.set_tls_name(ServerName::try_from("redis.internal").unwrap()); + + let config = builder.build().unwrap(); + let tls = config.tls_client.as_ref().unwrap(); + assert_eq!( + tls.tls_name, + ServerName::try_from("redis.internal").unwrap() + ); + } + + #[test] + fn build_tls_config_derives_server_name_from_ip_host() { + init_test_rustls_provider(); + let mut builder = RedisClientConfigBuilder::new(UpstreamAddr::new( + Host::Ip(IpAddr::from_str("10.0.0.5").unwrap()), + 6379, + )); + builder.set_tls_client(RustlsClientConfigBuilder::default()); + + let config = builder.build().unwrap(); + let tls = config.tls_client.as_ref().unwrap(); + assert_eq!(tls.tls_name, ServerName::try_from("10.0.0.5").unwrap()); + } +} diff --git a/lib/vey-redis-client/src/yaml.rs b/lib/vey-redis-client/src/yaml.rs index 895c70538..3fb6e7211 100644 --- a/lib/vey-redis-client/src/yaml.rs +++ b/lib/vey-redis-client/src/yaml.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::path::Path; @@ -124,6 +125,33 @@ mod tests { assert_eq!(builder, expected); } + #[test] + fn set_by_yaml_kv_address_alias() { + let mut builder = RedisClientConfigBuilder::default(); + let yaml = yaml_doc!(r#"address: "192.168.1.1:6399""#); + for (k, v) in yaml.as_hash().unwrap().iter() { + builder + .set_by_yaml_kv(k.as_str().unwrap(), v, None) + .unwrap(); + } + assert_eq!( + builder.addr, + UpstreamAddr::new(Host::Ip(IpAddr::from_str("192.168.1.1").unwrap()), 6399,) + ); + } + + #[test] + fn set_by_yaml_kv_read_timeout_alias() { + let mut builder = RedisClientConfigBuilder::default(); + let yaml = yaml_doc!(r#"read_timeout: "3s""#); + for (k, v) in yaml.as_hash().unwrap().iter() { + builder + .set_by_yaml_kv(k.as_str().unwrap(), v, None) + .unwrap(); + } + assert_eq!(builder.response_timeout, Duration::from_secs(3)); + } + #[test] fn set_by_yaml_kv_err() { let mut builder = RedisClientConfigBuilder::default(); diff --git a/lib/vey-resolver/src/record.rs b/lib/vey-resolver/src/record.rs index da00a2562..7cfbc4da9 100644 --- a/lib/vey-resolver/src/record.rs +++ b/lib/vey-resolver/src/record.rs @@ -192,4 +192,49 @@ mod tests { Duration::from_secs(100) ); } + + #[test] + fn record_source_as_str() { + assert_eq!(ResolvedRecordSource::Cache.as_str(), "cache"); + assert_eq!(format!("{}", ResolvedRecordSource::Query), "query"); + } + + #[test] + fn is_expired_without_expire_is_always_true() { + let mut record = ResolvedRecord::empty(domain(), 60); + record.expire = None; + assert!(record.is_expired(Instant::now())); + } + + #[test] + fn is_acceptable_only_for_not_found() { + let ok = + ResolvedRecord::resolved(domain(), 60, 30, 300, vec!["127.0.0.1".parse().unwrap()]); + assert!(ok.is_acceptable()); + + let not_found = ResolvedRecord::failed( + domain(), + 60, + ResolveError::ServerError(ResolveServerError::NotFound), + ); + assert!(not_found.is_acceptable()); + + let refused = ResolvedRecord::failed( + domain(), + 60, + ResolveError::ServerError(ResolveServerError::Refused), + ); + assert!(!refused.is_acceptable()); + } + + #[test] + fn is_usable_requires_non_empty_ips() { + let empty = ResolvedRecord::empty(domain(), 60); + assert!(empty.is_ok()); + assert!(!empty.is_usable()); + + let with_ip = + ResolvedRecord::resolved(domain(), 60, 30, 300, vec!["10.0.0.1".parse().unwrap()]); + assert!(with_ip.is_usable()); + } } diff --git a/lib/vey-reuseport/src/lib.rs b/lib/vey-reuseport/src/lib.rs index 21f840e59..76cecb703 100644 --- a/lib/vey-reuseport/src/lib.rs +++ b/lib/vey-reuseport/src/lib.rs @@ -38,3 +38,29 @@ struct ReadOnlyData { load_pid: i32, load_generation: u32, } + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + #[test] + fn bpf_struct_layout_matches_expectations() { + assert_eq!(size_of::(), 8); + assert_eq!(size_of::(), 8); + assert_eq!(size_of::(), 8); + assert_eq!(size_of::(), 8); + } + + #[test] + fn socket_id_serializes_to_expected_byte_length() { + let id = SocketId { + pid: 4242, + generation: 7, + worker: 3, + }; + let bytes = id.as_bytes(); + assert_eq!(bytes.len(), size_of::()); + assert_eq!(bytes.len(), 8); + } +} diff --git a/lib/vey-runtime/src/blended/mod.rs b/lib/vey-runtime/src/blended/mod.rs index c2328d25d..7628cd355 100644 --- a/lib/vey-runtime/src/blended/mod.rs +++ b/lib/vey-runtime/src/blended/mod.rs @@ -111,3 +111,31 @@ impl BlendedRuntimeConfig { .map_err(|e| anyhow!("runtime build failed: {e:?}")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intended_thread_number_minimum_one() { + let mut config = BlendedRuntimeConfig::new(); + config.set_thread_number(0); + assert_eq!(config.intended_thread_number(), 1); + } + + #[test] + fn run_in_current_thread_when_zero() { + let mut config = BlendedRuntimeConfig::new(); + assert!(!config.run_in_current_thread()); + config.set_thread_number(0); + assert!(config.run_in_current_thread()); + } + + #[test] + fn builder_current_thread_for_zero_threads() { + let mut config = BlendedRuntimeConfig::new(); + config.set_thread_number(0); + let rt = config.builder().build().unwrap(); + assert!(rt.handle().runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread); + } +} diff --git a/lib/vey-rustls-provider/src/lib.rs b/lib/vey-rustls-provider/src/lib.rs index 3fee6ddd0..2c536d30d 100644 --- a/lib/vey-rustls-provider/src/lib.rs +++ b/lib/vey-rustls-provider/src/lib.rs @@ -29,3 +29,29 @@ pub fn install_default() -> anyhow::Result<()> { pub fn provider_name() -> Option<&'static str> { PROVIDER_NAME } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_default_succeeds() { + install_default().expect("default rustls provider should install"); + } + + #[test] + fn provider_name_matches_enabled_feature() { + let name = provider_name().expect("build script should set provider name"); + cfg_if! { + if #[cfg(feature = "rustls-aws-lc-fips")] { + assert_eq!(name, "aws-lc-fips"); + } else if #[cfg(feature = "rustls-aws-lc")] { + assert_eq!(name, "aws-lc"); + } else if #[cfg(feature = "rustls-ring")] { + assert_eq!(name, "ring"); + } else { + compile_error!("no rustls provider feature enabled for tests"); + } + } + } +} diff --git a/lib/vey-slog-types/src/lib.rs b/lib/vey-slog-types/src/lib.rs index b25dbbdec..1255604cf 100644 --- a/lib/vey-slog-types/src/lib.rs +++ b/lib/vey-slog-types/src/lib.rs @@ -34,3 +34,6 @@ pub use self::http::{LtH2StreamId, LtHttpHeaderValue, LtHttpMethod, LtHttpUri}; mod openssl; #[cfg(feature = "openssl")] pub use self::openssl::LtX509VerifyResult; + +#[cfg(test)] +mod tests; diff --git a/lib/vey-slog-types/src/tests.rs b/lib/vey-slog-types/src/tests.rs new file mode 100644 index 000000000..ee056ec7d --- /dev/null +++ b/lib/vey-slog-types/src/tests.rs @@ -0,0 +1,145 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. + */ + +use std::fmt::Arguments; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::str::FromStr; +use std::time::Duration; + +use chrono::{TimeZone, Utc}; +use slog::{Key, Serializer, Value}; +use uuid::Uuid; +use vey_types::net::{Host, UpstreamAddr}; + +use crate::{LtDateTime, LtDuration, LtHost, LtIpAddr, LtSocketAddr, LtUpstreamAddr, LtUuid}; + +struct Capture { + key: String, + value: String, +} + +struct CaptureSerializer(Capture); + +impl CaptureSerializer { + fn new() -> Self { + CaptureSerializer(Capture { + key: String::new(), + value: String::new(), + }) + } + + fn into_pair(self) -> (String, String) { + (self.0.key, self.0.value) + } +} + +impl Serializer for CaptureSerializer { + fn emit_arguments(&mut self, key: Key, val: &Arguments) -> slog::Result { + self.0.key = key.as_str().to_string(); + self.0.value = format!("{val}"); + Ok(()) + } + + fn emit_str(&mut self, key: Key, val: &str) -> slog::Result { + self.0.key = key.as_str().to_string(); + self.0.value = val.to_string(); + Ok(()) + } + + fn emit_none(&mut self, key: Key) -> slog::Result { + self.0.key = key.as_str().to_string(); + self.0.value = "".to_string(); + Ok(()) + } +} + +fn serialize_value(value: V) -> (String, String) { + static LOC: slog::RecordLocation = slog::RecordLocation { + file: file!(), + line: line!(), + column: 0, + module: module_path!(), + function: "", + }; + static RS: slog::RecordStatic = slog::RecordStatic { + location: &LOC, + tag: "", + level: slog::Level::Info, + }; + let msg = format_args!(""); + let record = slog::Record::new(&RS, &msg, slog::b!()); + let mut ser = CaptureSerializer::new(); + value.serialize(&record, "k".into(), &mut ser).unwrap(); + ser.into_pair() +} + +#[test] +fn datetime_rfc3339_micros() { + let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 30, 45).unwrap(); + let (_, value) = serialize_value(LtDateTime(&dt)); + assert_eq!(value, "2024-06-15T12:30:45.000000Z"); +} + +#[test] +fn duration_zero_emits_none() { + let (_, value) = serialize_value(LtDuration(Duration::ZERO)); + assert_eq!(value, ""); +} + +#[test] +fn duration_nonzero_debug() { + let (_, value) = serialize_value(LtDuration(Duration::from_millis(1500))); + assert_eq!(value, "1.500s"); +} + +#[test] +fn ip_addr_display() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let (_, value) = serialize_value(LtIpAddr(ip)); + assert_eq!(value, "10.0.0.1"); +} + +#[test] +fn socket_addr_display() { + let addr = SocketAddr::from_str("127.0.0.1:8080").unwrap(); + let (_, value) = serialize_value(LtSocketAddr(addr)); + assert_eq!(value, "127.0.0.1:8080"); +} + +#[test] +fn upstream_addr_empty_emits_none() { + let upstream = UpstreamAddr::empty(); + let (_, value) = serialize_value(LtUpstreamAddr(&upstream)); + assert_eq!(value, ""); +} + +#[test] +fn host_domain() { + let host = Host::from_domain_str("example.com").unwrap(); + let (_, value) = serialize_value(LtHost(&host)); + assert_eq!(value, "example.com"); +} + +#[test] +fn host_ip() { + let host = Host::Ip(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let (_, value) = serialize_value(LtHost(&host)); + assert_eq!(value, "127.0.0.1"); +} + +#[test] +fn host_empty_emits_none() { + let host = Host::empty(); + let (_, value) = serialize_value(LtHost(&host)); + assert_eq!(value, ""); +} + +#[test] +fn uuid_simple_format() { + let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let (_, value) = serialize_value(LtUuid(&id)); + assert_eq!(value, "550e8400e29b41d4a716446655440000"); +} diff --git a/lib/vey-smtp-proto/src/response/parser.rs b/lib/vey-smtp-proto/src/response/parser.rs index b3cd1e382..39833a39c 100644 --- a/lib/vey-smtp-proto/src/response/parser.rs +++ b/lib/vey-smtp-proto/src/response/parser.rs @@ -238,4 +238,35 @@ mod tests { assert_eq!(msg, b""); assert!(rsp.finished()); } + + #[test] + fn reply_code_as_u16() { + assert_eq!(ReplyCode::SERVICE_READY.as_u16(), 220); + assert_eq!(ReplyCode::OK.as_u16(), 250); + assert_eq!(ReplyCode::AUTHENTICATION_REQUIRED.as_u16(), 530); + } + + #[test] + fn reply_code_new_rejects_invalid_first_digit() { + assert!(ReplyCode::new(b'1', b'2', b'0').is_none()); + assert!(ReplyCode::new(b'6', b'0', b'0').is_none()); + } + + #[test] + fn is_first_line_after_initial_feed() { + let line = b"250-first line\r\n"; + let mut rsp = ResponseParser::default(); + rsp.feed_line(line).unwrap(); + assert!(rsp.is_first_line()); + } + + #[test] + fn following_line_code_mismatch_is_invalid() { + let line1 = b"250-first\r\n"; + let line2 = b"251-second\r\n"; + let mut rsp = ResponseParser::default(); + rsp.feed_line(line1).unwrap(); + let err = rsp.feed_line(line2).unwrap_err(); + assert_eq!(err, ResponseLineError::InvalidCode); + } } diff --git a/lib/vey-socket/src/bind.rs b/lib/vey-socket/src/bind.rs index 68ff7b38d..b5f9b52fb 100644 --- a/lib/vey-socket/src/bind.rs +++ b/lib/vey-socket/src/bind.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::io; @@ -61,7 +62,9 @@ impl BindAddr { BindAddr::Foreign(addr) => Some(addr.ip()), } } +} +impl BindAddr { pub(crate) fn bind_tcp_for_connect( &self, socket: &Socket, @@ -225,3 +228,27 @@ impl BindAddr { socket.bind(&bind_addr) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn none_is_none() { + assert!(BindAddr::None.is_none()); + assert!(!BindAddr::Ip(IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); + } + + #[test] + fn ip_returns_address() { + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)); + assert_eq!(BindAddr::Ip(ip).ip(), Some(ip)); + assert_eq!(BindAddr::None.ip(), None); + } + + #[test] + fn default_is_none() { + assert!(BindAddr::default().is_none()); + } +} diff --git a/lib/vey-socket/src/util.rs b/lib/vey-socket/src/util.rs index fabc680de..9bff1e674 100644 --- a/lib/vey-socket/src/util.rs +++ b/lib/vey-socket/src/util.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2023-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::fmt; @@ -62,6 +63,7 @@ pub fn native_socket_addr(orig: SocketAddr) -> SocketAddr { #[cfg(test)] mod tests { use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; #[test] @@ -76,4 +78,24 @@ mod tests { let addr1 = SocketAddr::from_str("192.168.0.1:80").unwrap(); assert_eq!(native_socket_addr(addr1), addr1); } + + #[test] + fn address_family_display_and_from_ip() { + assert_eq!(AddressFamily::Ipv4.to_string(), "Ipv4"); + assert_eq!(AddressFamily::Ipv6.to_string(), "Ipv6"); + + let v4 = IpAddr::V4(Ipv4Addr::LOCALHOST); + let v6 = IpAddr::V6(Ipv6Addr::LOCALHOST); + assert_eq!(AddressFamily::from(&v4), AddressFamily::Ipv4); + assert_eq!(AddressFamily::from(&v6), AddressFamily::Ipv6); + assert_eq!(Domain::from(AddressFamily::Ipv4), Domain::IPV4); + } + + #[test] + fn address_family_from_socket_addr() { + let v4 = SocketAddr::from_str("127.0.0.1:8080").unwrap(); + let v6 = SocketAddr::from_str("[::1]:8080").unwrap(); + assert_eq!(AddressFamily::from(&v4), AddressFamily::Ipv4); + assert_eq!(AddressFamily::from(&v6), AddressFamily::Ipv6); + } } diff --git a/lib/vey-socks/src/v5/auth.rs b/lib/vey-socks/src/v5/auth.rs index 8d6733a91..8ee382fe6 100644 --- a/lib/vey-socks/src/v5/auth.rs +++ b/lib/vey-socks/src/v5/auth.rs @@ -184,3 +184,35 @@ where let buf = [0x01, 0x01]; clt_w.write_all_flush(&buf).await } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, duplex}; + + #[tokio::test] + async fn send_method_to_client_writes_version_and_code() { + let (mut client, mut server) = duplex(16); + send_method_to_client(&mut client, &SocksAuthMethod::User) + .await + .unwrap(); + drop(client); + + let mut buf = [0u8; 2]; + server.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, [0x05, 0x02]); + } + + #[tokio::test] + async fn send_methods_to_remote_none_auth() { + let (mut client, mut server) = duplex(16); + send_methods_to_remote(&mut client, &SocksAuth::None) + .await + .unwrap(); + drop(client); + + let mut buf = [0u8; 3]; + server.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, [0x05, 0x01, 0x00]); + } +} diff --git a/lib/vey-socks/src/v5/quic.rs b/lib/vey-socks/src/v5/quic.rs index 27a898afe..b6c256b5b 100644 --- a/lib/vey-socks/src/v5/quic.rs +++ b/lib/vey-socks/src/v5/quic.rs @@ -152,6 +152,19 @@ impl AsMut<[u8]> for SocksHeaderBuffer { } } +/// SOCKS5 UDP ASSOCIATE socket exposed as a Quinn [`AsyncUdpSocket`]. +/// +/// # Concurrency +/// +/// Quinn requires `AsyncUdpSocket: Sync` so the socket can be shared via `Arc` +/// across connection tasks that call [`AsyncUdpSocket::try_send`]. Receive-side +/// state (`ctl_close_receiver`) is mutated through [`UnsafeCell`] inside +/// [`AsyncUdpSocket::poll_recv`], which is **not** safe to call concurrently. +/// +/// This type must only be used with a Quinn [`quinn::Endpoint`]: Quinn drives +/// UDP receive from a single `EndpointDriver` task (serialized under the +/// endpoint state mutex), so `poll_recv` is never invoked concurrently. +/// Calling `poll_recv` from multiple tasks yourself is undefined behavior. #[derive(Debug)] pub struct Socks5UdpSocket { io: tokio::net::UdpSocket, @@ -160,6 +173,9 @@ pub struct Socks5UdpSocket { send_socks_header: SocksHeaderBuffer, } +// SAFETY: See type-level docs. Sync is required by Quinn's AsyncUdpSocket; +// exclusive access to ctl_close_receiver is guaranteed by Quinn's single +// EndpointDriver receive path (no concurrent poll_recv). unsafe impl Sync for Socks5UdpSocket {} impl Socks5UdpSocket { @@ -249,6 +265,8 @@ impl AsyncUdpSocket for Socks5UdpSocket { ) -> Poll> { use smallvec::{SmallVec, smallvec}; + // SAFETY: poll_recv is only driven by Quinn's single EndpointDriver + // (see type-level docs); no concurrent &mut to ctl_close_receiver. let ctl_close_receiver = unsafe { &mut *self.ctl_close_receiver.get() }; match Pin::new(ctl_close_receiver).poll(cx) { Poll::Pending => {} @@ -292,6 +310,8 @@ impl AsyncUdpSocket for Socks5UdpSocket { meta: &mut [RecvMeta], ) -> Poll> { // logics from quinn-udp::fallback.rs + // SAFETY: poll_recv is only driven by Quinn's single EndpointDriver + // (see type-level docs); no concurrent &mut to ctl_close_receiver. let ctl_close_receiver = unsafe { &mut *self.ctl_close_receiver.get() }; match Pin::new(ctl_close_receiver).poll(cx) { Poll::Pending => {} diff --git a/lib/vey-statsd-client/src/client/mod.rs b/lib/vey-statsd-client/src/client/mod.rs index 8cc133628..78904a2a5 100644 --- a/lib/vey-statsd-client/src/client/mod.rs +++ b/lib/vey-statsd-client/src/client/mod.rs @@ -177,4 +177,34 @@ mod tests { b"test.count:20|c|#c1:v1,c2:v2\ntest.count:30|c|#c1:v1\n" ); } + + #[test] + fn gauge_float_simple() { + let buf = Rc::new(Mutex::new(Vec::default())); + let sink = StatsdMetricsSink::test_with_capacity(buf.clone(), 32); + let prefix = unsafe { NodeName::new_unchecked("test") }; + let mut client = StatsdClient::new(prefix, sink); + client.gauge_float("latency", 1.25).send(); + client.flush_sink(); + + let buf = buf.lock().unwrap(); + assert_eq!(buf.as_slice(), b"test.latency:1.25|g\n"); + } + + #[test] + fn gauge_float_with_tags() { + let buf = Rc::new(Mutex::new(Vec::default())); + let sink = StatsdMetricsSink::test_with_capacity(buf.clone(), 64); + let mut common_tags = StatsdTagGroup::default(); + common_tags.add_tag("host", "a"); + let mut client = StatsdClient::new(NodeName::default(), sink); + client + .gauge_float_with_tags("load", 0.5, &common_tags) + .with_tag("zone", "z1") + .send(); + client.flush_sink(); + + let buf = buf.lock().unwrap(); + assert_eq!(buf.as_slice(), b"load:0.5|g|#host:a,zone:z1\n"); + } } diff --git a/lib/vey-statsd-client/src/sink/buf.rs b/lib/vey-statsd-client/src/sink/buf.rs index d21b7128b..d73f00766 100644 --- a/lib/vey-statsd-client/src/sink/buf.rs +++ b/lib/vey-statsd-client/src/sink/buf.rs @@ -90,3 +90,46 @@ impl<'a> Iterator for SinkBufIter<'a> { Some(IoSlice::new(&left_data[..to_read])) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn receive_tracks_message_lengths() { + let mut buf = SinkBuf::new(64); + buf.receive(|b| b.extend_from_slice(b"one\n")); + buf.receive(|b| b.extend_from_slice(b"two\n")); + assert!(!buf.is_empty()); + assert_eq!(buf.msg_length_vec, vec![4, 4]); + } + + #[test] + fn iter_respects_segment_size() { + let mut buf = SinkBuf::new(64); + buf.receive(|b| b.extend_from_slice(b"aaa\n")); + buf.receive(|b| b.extend_from_slice(b"bbbb\n")); + let slices: Vec<_> = buf.iter(5).collect(); + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 4); + assert_eq!(slices[1].len(), 5); + } + + #[test] + fn iter_emits_oversized_message_as_single_slice() { + let mut buf = SinkBuf::new(64); + buf.receive(|b| b.extend_from_slice(b"0123456789\n")); + let slices: Vec<_> = buf.iter(4).collect(); + assert_eq!(slices.len(), 1); + assert_eq!(slices[0].len(), 11); + } + + #[test] + fn reset_clears_buffer() { + let mut buf = SinkBuf::new(16); + buf.receive(|b| b.push(b'x')); + buf.reset(); + assert!(buf.is_empty()); + assert!(buf.msg_length_vec.is_empty()); + } +} diff --git a/lib/vey-statsd-client/src/tag.rs b/lib/vey-statsd-client/src/tag.rs index 6172bb009..c2477bfbf 100644 --- a/lib/vey-statsd-client/src/tag.rs +++ b/lib/vey-statsd-client/src/tag.rs @@ -43,3 +43,46 @@ impl StatsdTagGroup { self.buf.as_slice() } } + +#[cfg(test)] +mod tests { + use super::*; + use vey_types::metrics::MetricTagMap; + + #[test] + fn add_tag_formats_key_value() { + let mut tags = StatsdTagGroup::default(); + tags.add_tag("host", "web1"); + tags.add_tag("region", "us"); + assert_eq!(tags.as_bytes(), b"host:web1,region:us"); + } + + #[test] + fn add_tag_value_appends_bare_values() { + let mut tags = StatsdTagGroup::default(); + tags.add_tag_value("alpha"); + tags.add_tag_value("beta"); + assert_eq!(tags.as_bytes(), b"alpha,beta"); + } + + #[test] + fn add_static_tags_from_map() { + use std::str::FromStr; + + use vey_types::metrics::{MetricTagName, MetricTagValue}; + + let mut map = MetricTagMap::default(); + map.insert( + MetricTagName::from_str("env").unwrap(), + MetricTagValue::from_str("prod").unwrap(), + ); + map.insert( + MetricTagName::from_str("svc").unwrap(), + MetricTagValue::from_str("proxy").unwrap(), + ); + + let mut tags = StatsdTagGroup::default(); + tags.add_static_tags(&map); + assert_eq!(tags.as_bytes(), b"env:prod,svc:proxy"); + } +} diff --git a/lib/vey-std-ext/src/time/duration.rs b/lib/vey-std-ext/src/time/duration.rs index 26ea62ce4..528464fee 100644 --- a/lib/vey-std-ext/src/time/duration.rs +++ b/lib/vey-std-ext/src/time/duration.rs @@ -22,3 +22,27 @@ impl DurationExt for Duration { u64::try_from(self.as_nanos()).unwrap_or(u64::MAX) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn millis_f64() { + assert_eq!( + DurationExt::as_millis_f64(&Duration::from_millis(1500)), + 1500.0 + ); + assert_eq!(DurationExt::as_millis_f64(&Duration::from_secs(2)), 2000.0); + assert!( + (DurationExt::as_millis_f64(&Duration::from_nanos(1_500_000)) - 1.5).abs() + < f64::EPSILON + ); + } + + #[test] + fn nanos_u64() { + assert_eq!(Duration::from_nanos(100).as_nanos_u64(), 100); + assert_eq!(Duration::MAX.as_nanos_u64(), u64::MAX); + } +} diff --git a/lib/vey-stdlog/src/lib.rs b/lib/vey-stdlog/src/lib.rs index 2c467fa4c..fb20a28f5 100644 --- a/lib/vey-stdlog/src/lib.rs +++ b/lib/vey-stdlog/src/lib.rs @@ -203,3 +203,19 @@ impl AsyncIoThread { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_message_renders_as_unit() { + let value = StdLogValue { + level: Level::Info, + message: String::new(), + kv_pairs: vec![], + location: None, + }; + assert_eq!(value.message_str(), "()"); + } +} diff --git a/lib/vey-syslog/src/format/cee.rs b/lib/vey-syslog/src/format/cee.rs index a820dbafa..c451a94bd 100644 --- a/lib/vey-syslog/src/format/cee.rs +++ b/lib/vey-syslog/src/format/cee.rs @@ -121,3 +121,28 @@ fn format_content_as_json( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + use super::SyslogFormatter; + + #[test] + fn cee_event_flag_constant() { + assert_eq!(CEE_EVENT_FLAG, "@cee:"); + } + + #[test] + fn rfc3164_cee_formatter_lifecycle() { + let mut formatter = FormatterRfc3164Cee::new("@cee:".into()); + formatter.append_report_ts(true); + formatter.append_report_ts(false); + } + + #[test] + fn rfc5424_cee_formatter_lifecycle() { + let mut formatter = FormatterRfc5424Cee::new(Some("MID-1".into()), "@cee:".into()); + formatter.append_report_ts(true); + } +} diff --git a/lib/vey-syslog/src/types.rs b/lib/vey-syslog/src/types.rs index 45f3eae71..1ad114d71 100644 --- a/lib/vey-syslog/src/types.rs +++ b/lib/vey-syslog/src/types.rs @@ -62,3 +62,23 @@ pub enum Severity { } pub type Priority = u8; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn facility_values_are_multiples_of_eight() { + assert_eq!(Facility::Kern as u8, 0); + assert_eq!(Facility::User as u8, 8); + assert_eq!(Facility::Mail as u8, 16); + assert_eq!(Facility::Local7 as u8, 23 << 3); + } + + #[test] + fn severity_values_are_sequential() { + assert_eq!(Severity::Emergency as u8, 0); + assert_eq!(Severity::Alert as u8, 1); + assert_eq!(Severity::Debug as u8, 7); + } +} diff --git a/lib/vey-tls-cert/src/builder/key_usage.rs b/lib/vey-tls-cert/src/builder/key_usage.rs index 35ff84650..68cbbfef8 100644 --- a/lib/vey-tls-cert/src/builder/key_usage.rs +++ b/lib/vey-tls-cert/src/builder/key_usage.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2024-2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use openssl::error::ErrorStack; @@ -60,3 +61,40 @@ impl KeyUsageBuilder { self.0.build() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_key_usage_builds(builder: KeyUsageBuilder) { + let ext = builder.build().unwrap(); + assert!(!ext.to_der().unwrap().is_empty()); + } + + #[test] + fn ca_usage_builds_critical_extension() { + assert_key_usage_builds(KeyUsageBuilder::ca()); + } + + #[test] + fn tls_general_usage_builds_critical_extension() { + assert_key_usage_builds(KeyUsageBuilder::tls_general()); + } + + #[test] + fn ed_dsa_usage_builds_critical_extension() { + assert_key_usage_builds(KeyUsageBuilder::ed_dsa()); + } + + #[test] + fn x_dh_usage_builds_critical_extension() { + assert_key_usage_builds(KeyUsageBuilder::x_dh()); + } + + #[test] + fn tlcp_usages_build_distinct_extensions() { + let sign = KeyUsageBuilder::tlcp_sign().build().unwrap(); + let enc = KeyUsageBuilder::tlcp_enc().build().unwrap(); + assert_ne!(sign.to_der().unwrap(), enc.to_der().unwrap()); + } +} diff --git a/lib/vey-tls-cert/src/builder/serial.rs b/lib/vey-tls-cert/src/builder/serial.rs index 44092e513..2ce2caff4 100644 --- a/lib/vey-tls-cert/src/builder/serial.rs +++ b/lib/vey-tls-cert/src/builder/serial.rs @@ -14,3 +14,21 @@ pub fn random_16() -> anyhow::Result { bn.to_asn1_integer() .map_err(|e| anyhow!("failed to convert bn to asn1 integer: {e}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_16_is_positive() { + let serial = random_16().unwrap(); + assert!(serial.to_bn().unwrap().num_bits() > 0); + } + + #[test] + fn random_16_unique() { + let a = random_16().unwrap(); + let b = random_16().unwrap(); + assert_ne!(a.to_bn().unwrap(), b.to_bn().unwrap()); + } +} diff --git a/lib/vey-tls-cert/src/builder/subject.rs b/lib/vey-tls-cert/src/builder/subject.rs index 8266e4fb5..98aa22bae 100644 --- a/lib/vey-tls-cert/src/builder/subject.rs +++ b/lib/vey-tls-cert/src/builder/subject.rs @@ -86,3 +86,44 @@ impl SubjectNameBuilder { Ok(builder.build()) } } + +#[cfg(test)] +mod tests { + use super::*; + use openssl::nid::Nid; + + #[test] + fn build_full_subject() { + let mut builder = SubjectNameBuilder::default(); + builder.set_country("US".to_string()); + builder.set_organization("Example Inc".to_string()); + builder.set_organization_unit("Engineering".to_string()); + builder.set_common_name("example.com".to_string()); + + let name = builder.build().unwrap(); + assert_eq!(name.entries_by_nid(Nid::COUNTRYNAME).count(), 1); + assert_eq!(name.entries_by_nid(Nid::ORGANIZATIONNAME).count(), 1); + assert_eq!(name.entries_by_nid(Nid::ORGANIZATIONALUNITNAME).count(), 1); + assert_eq!(name.entries_by_nid(Nid::COMMONNAME).count(), 1); + } + + #[test] + fn set_common_name_if_missing() { + let mut builder = SubjectNameBuilder::default(); + builder.set_common_name_if_missing("fallback.example"); + assert_eq!(builder.common_name(), Some("fallback.example")); + + builder.set_common_name("primary.example".to_string()); + builder.set_common_name_if_missing("fallback.example"); + assert_eq!(builder.common_name(), Some("primary.example")); + } + + #[test] + fn build_with_default_common_name() { + let builder = SubjectNameBuilder::default(); + let name = builder + .build_with_default_common_name("default.example") + .unwrap(); + assert_eq!(name.entries_by_nid(Nid::COMMONNAME).count(), 1); + } +} diff --git a/lib/vey-tls-cert/src/builder/time.rs b/lib/vey-tls-cert/src/builder/time.rs index 74aa12f38..3dea0dba3 100644 --- a/lib/vey-tls-cert/src/builder/time.rs +++ b/lib/vey-tls-cert/src/builder/time.rs @@ -15,3 +15,23 @@ pub(super) fn asn1_time_from_chrono(datetime: &DateTime) -> anyhow::Result< }; Asn1Time::from_str(&format!("{lazy_fmt}")).map_err(|e| anyhow!("failed to get asn1 time: {e}")) } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + #[test] + fn utc_format_before_2050() { + let dt = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); + let asn1 = asn1_time_from_chrono(&dt).unwrap(); + assert_eq!(asn1.to_string(), "Jan 15 10:30:00 2024 GMT"); + } + + #[test] + fn generalized_time_at_or_after_2050() { + let dt = Utc.with_ymd_and_hms(2050, 6, 1, 0, 0, 0).unwrap(); + let asn1 = asn1_time_from_chrono(&dt).unwrap(); + assert_eq!(asn1.to_string(), "Jun 1 00:00:00 2050 GMT"); + } +} diff --git a/lib/vey-tls-ticket/src/config/mod.rs b/lib/vey-tls-ticket/src/config/mod.rs index 57e1c2c2d..2c4c466c9 100644 --- a/lib/vey-tls-ticket/src/config/mod.rs +++ b/lib/vey-tls-ticket/src/config/mod.rs @@ -43,3 +43,22 @@ impl TlsTicketConfig { Ok(ticketer) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_values() { + let config = TlsTicketConfig::default(); + assert_eq!(config.check_interval, Duration::from_secs(300)); + assert_eq!(config.local_lifetime, 12 * 60 * 60); + assert!(config.remote_source.is_none()); + } + + #[test] + fn config_clone_is_equal() { + let config = TlsTicketConfig::default(); + assert_eq!(config, config.clone()); + } +} diff --git a/lib/vey-udpdump/src/dissector.rs b/lib/vey-udpdump/src/dissector.rs index 2eaaa8816..97251b668 100644 --- a/lib/vey-udpdump/src/dissector.rs +++ b/lib/vey-udpdump/src/dissector.rs @@ -87,3 +87,44 @@ fn serialize_dissector_table_name_num_val(buf: &mut Vec, port: u16) { port[1], ]); } + +#[cfg(test)] +mod tests { + use super::*; + use vey_dpi::Protocol; + + #[test] + fn serialize_exported_pdu_prefix() { + let mut buf = Vec::new(); + ExportedPduDissectorHint::serialize_exported_pdu(&mut buf); + assert!(buf.windows(12).any(|w| w == b"exported_pdu")); + } + + #[test] + fn serialize_tcp_port_hint() { + let mut buf = Vec::new(); + ExportedPduDissectorHint::TcpPort(443).serialize(&mut buf); + assert!(buf.windows(8).any(|w| w == b"tcp.port")); + assert!(buf.ends_with(&443u16.to_be_bytes())); + } + + #[test] + fn serialize_tls_port_hint() { + let mut buf = Vec::new(); + ExportedPduDissectorHint::TlsPort(8443).serialize(&mut buf); + assert!(buf.windows(8).any(|w| w == b"tls.port")); + assert!(buf.ends_with(&8443u16.to_be_bytes())); + } + + #[test] + fn serialize_protocol_hint_http() { + let mut buf = Vec::new(); + ExportedPduDissectorHint::Protocol(Protocol::Http1).serialize(&mut buf); + let dissector = Protocol::Http1.wireshark_dissector(); + assert!(!dissector.is_empty()); + assert!( + buf.windows(dissector.len()) + .any(|w| w == dissector.as_bytes()) + ); + } +} diff --git a/lib/vey-udpdump/src/stream/header.rs b/lib/vey-udpdump/src/stream/header.rs index b09062d8b..0d9a6ab5b 100644 --- a/lib/vey-udpdump/src/stream/header.rs +++ b/lib/vey-udpdump/src/stream/header.rs @@ -433,3 +433,45 @@ fn push_var_tcp_header(buf: &mut Vec) { // end of option buf.extend_from_slice(&[0x00, EXP_PDU_TAG_END_OF_OPT, 0x00, 0x00]); } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + use crate::ExportedPduDissectorHint; + + #[test] + fn to_client_header_updates_tcp_seq() { + let client = SocketAddr::from_str("192.168.1.10:50000").unwrap(); + let remote = SocketAddr::from_str("203.0.113.1:443").unwrap(); + let (mut to_client, _) = new_pair(client, remote, ExportedPduDissectorHint::TlsPort(443)); + + let mut hdr = to_client.new_header(512); + to_client.update_tcp_dissector_data(&mut hdr, 100); + to_client.record_written_data(100); + + let offset = hdr + .windows(2) + .position(|w| w == [0x00, EXP_PDU_TAG_TCP_INFO_DATA]) + .expect("tcp info tag") + + 6; + let seq = u32::from_be_bytes(hdr[offset..offset + 4].try_into().unwrap()); + assert_eq!(seq, 1); + let next_seq = u32::from_be_bytes(hdr[offset + 4..offset + 8].try_into().unwrap()); + assert_eq!(next_seq, 101); + } + + #[test] + fn header_contains_ipv4_addresses() { + let client = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 1234); + let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 443); + let (mut to_client, _) = new_pair(client, remote, ExportedPduDissectorHint::TcpPort(443)); + + let hdr = to_client.new_header(256); + assert!(hdr.contains(&10)); + assert!(hdr.contains(&2)); + assert!(hdr.contains(&1)); + } +} diff --git a/lib/vey-xcrypt/src/lib.rs b/lib/vey-xcrypt/src/lib.rs index d261ac816..80e0411be 100644 --- a/lib/vey-xcrypt/src/lib.rs +++ b/lib/vey-xcrypt/src/lib.rs @@ -85,4 +85,23 @@ mod tests { let crypt = XCryptHash::parse(s).unwrap(); assert!(crypt.verify("123456".as_bytes()).unwrap()); } + + #[test] + fn unknown_prefix_rejected() { + assert!(matches!( + XCryptHash::parse("$2a$salt$hash"), + Err(XCryptParseError::UnknownPrefix) + )); + } + + #[test] + fn md5_wrong_phrase_fails() { + let crypt = XCryptHash::parse("$1$DDiGYGte$K/SAC4VvllDonGcP1EfaY1").unwrap(); + assert!(!crypt.verify(b"wrong").unwrap()); + } + + #[test] + fn sha256_invalid_rounds_rejected() { + assert!(XCryptHash::parse("$5$rounds=abc$salt$hash").is_err()); + } } diff --git a/vey-statsd/src/config/collector/discard.rs b/vey-statsd/src/config/collector/discard.rs index 198d48d8b..1ed0d3438 100644 --- a/vey-statsd/src/config/collector/discard.rs +++ b/vey-statsd/src/config/collector/discard.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use anyhow::anyhow; @@ -86,3 +87,20 @@ impl CollectorConfig for DiscardCollectorConfig { CollectorConfigDiffAction::NoAction } } + +#[cfg(test)] +mod tests { + use super::*; + use yaml_rust::YamlLoader; + + #[test] + fn parse_ok_and_rejects_missing_name() { + let docs = YamlLoader::load_from_str("name: drop\n").unwrap(); + let cfg = DiscardCollectorConfig::parse(docs[0].as_hash().unwrap(), None).unwrap(); + assert_eq!(cfg.name().as_str(), "drop"); + assert_eq!(cfg.collector_type(), "Discard"); + + let docs = YamlLoader::load_from_str("type: Discard\n").unwrap(); + assert!(DiscardCollectorConfig::parse(docs[0].as_hash().unwrap(), None).is_err()); + } +} diff --git a/vey-statsd/src/config/exporter/graphite.rs b/vey-statsd/src/config/exporter/graphite.rs index 0e139a77b..fa1a52619 100644 --- a/vey-statsd/src/config/exporter/graphite.rs +++ b/vey-statsd/src/config/exporter/graphite.rs @@ -158,6 +158,7 @@ impl ExporterConfig for GraphiteExporterConfig { #[cfg(test)] mod tests { use super::*; + use yaml_rust::YamlLoader; #[test] fn parse_counter_value() { @@ -170,5 +171,42 @@ mod tests { GraphiteCounterValue::Diff ); assert!(GraphiteCounterValue::from_str("rate").is_err()); + assert_eq!( + GraphiteCounterValue::parse_yaml(&Yaml::String("diff".into())).unwrap(), + GraphiteCounterValue::Diff + ); + assert!(GraphiteCounterValue::parse_yaml(&Yaml::Boolean(true)).is_err()); + } + + #[test] + fn parse_exporter_config() { + let docs = YamlLoader::load_from_str( + r#" +name: g1 +server: 127.0.0.1 +port: 2003 +emit_interval: 5s +prefix: app.metrics +counter_value: diff +"#, + ) + .unwrap(); + let cfg = GraphiteExporterConfig::parse(docs[0].as_hash().unwrap(), None).unwrap(); + assert_eq!(cfg.name().as_str(), "g1"); + assert_eq!(cfg.emit_interval, Duration::from_secs(5)); + assert_eq!(cfg.counter_value, GraphiteCounterValue::Diff); + assert_eq!( + cfg.prefix.as_ref().unwrap().display('.').to_string(), + "app.metrics" + ); + } + + #[test] + fn parse_requires_name_and_server() { + let docs = YamlLoader::load_from_str("server: 127.0.0.1\n").unwrap(); + assert!(GraphiteExporterConfig::parse(docs[0].as_hash().unwrap(), None).is_err()); + + let docs = YamlLoader::load_from_str("name: g1\n").unwrap(); + assert!(GraphiteExporterConfig::parse(docs[0].as_hash().unwrap(), None).is_err()); } } diff --git a/vey-statsd/src/config/exporter/influxdb/precision.rs b/vey-statsd/src/config/exporter/influxdb/precision.rs index 6fb78c7ba..aedba61cf 100644 --- a/vey-statsd/src/config/exporter/influxdb/precision.rs +++ b/vey-statsd/src/config/exporter/influxdb/precision.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::str::FromStr; @@ -59,3 +60,52 @@ impl FromStr for TimestampPrecision { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_str_aliases() { + assert_eq!( + TimestampPrecision::from_str("SECONDS").unwrap(), + TimestampPrecision::Seconds + ); + assert_eq!( + TimestampPrecision::from_str("ms").unwrap(), + TimestampPrecision::MilliSeconds + ); + assert_eq!( + TimestampPrecision::from_str("microseconds").unwrap(), + TimestampPrecision::MicroSeconds + ); + assert_eq!( + TimestampPrecision::from_str("ns").unwrap(), + TimestampPrecision::NanoSeconds + ); + assert!(TimestampPrecision::from_str("hour").is_err()); + } + + #[test] + fn query_values() { + assert_eq!(TimestampPrecision::Seconds.v2_query_value(), "s"); + assert_eq!(TimestampPrecision::MilliSeconds.v2_query_value(), "ms"); + assert_eq!( + TimestampPrecision::MicroSeconds.v3_query_value(), + "microsecond" + ); + assert_eq!( + TimestampPrecision::NanoSeconds.v3_query_value(), + "nanosecond" + ); + } + + #[test] + fn parse_yaml() { + assert_eq!( + TimestampPrecision::parse_yaml(&Yaml::String("us".into())).unwrap(), + TimestampPrecision::MicroSeconds + ); + assert!(TimestampPrecision::parse_yaml(&Yaml::Integer(1)).is_err()); + } +} diff --git a/vey-statsd/src/export/graphite/format.rs b/vey-statsd/src/export/graphite/format.rs index 4e743e4d5..255396f18 100644 --- a/vey-statsd/src/export/graphite/format.rs +++ b/vey-statsd/src/export/graphite/format.rs @@ -120,3 +120,95 @@ impl StreamExport for GraphitePlaintextStreamExport { pieces.len() } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use yaml_rust::YamlLoader; + + use crate::config::exporter::graphite::GraphiteExporterConfig; + + fn export( + yaml: &str, + ) -> ( + GraphitePlaintextAggregateExport, + mpsc::UnboundedReceiver>, + ) { + let docs = YamlLoader::load_from_str(yaml).unwrap(); + let cfg = GraphiteExporterConfig::parse(docs[0].as_hash().unwrap(), None).unwrap(); + let (tx, rx) = mpsc::unbounded_channel(); + (GraphitePlaintextAggregateExport::new(&cfg, tx), rx) + } + + #[test] + fn serialize_with_prefix_and_tags() { + let (mut export, _) = export( + r#" +name: g1 +server: 127.0.0.1 +prefix: pref +global_tags: + env: prod +"#, + ); + let time = Utc.with_ymd_and_hms(2020, 1, 2, 3, 4, 5).unwrap(); + let name = MetricName::parse("foo.bar").unwrap(); + let mut tags = MetricTagMap::default(); + tags.parse_statsd(b"k:v").unwrap(); + export.serialize(&time, &name, &tags, &MetricValue::Unsigned(9)); + let line = std::str::from_utf8(&export.buf).unwrap(); + assert!(line.starts_with("pref.foo.bar;")); + assert!(line.contains("env=prod")); + assert!(line.contains("k=v")); + assert!(line.contains(" 9 ")); + assert!(line.ends_with('\n')); + assert!(line.contains(&time.timestamp().to_string())); + } + + #[test] + fn emit_counter_uses_sum_or_diff() { + let name = MetricName::parse("c").unwrap(); + let tags = Arc::new(MetricTagMap::default()); + let mut values = AHashMap::new(); + values.insert( + tags, + CounterStoreValue { + time: Utc::now(), + sum: MetricValue::Unsigned(100), + diff: MetricValue::Unsigned(7), + }, + ); + + let (mut sum_export, mut sum_rx) = export( + r#" +name: g1 +server: 127.0.0.1 +counter_value: sum +"#, + ); + sum_export.emit_counter(&name, &values); + let buf = sum_rx.try_recv().unwrap(); + assert!(std::str::from_utf8(&buf).unwrap().contains(" 100 ")); + + let (mut diff_export, mut diff_rx) = export( + r#" +name: g1 +server: 127.0.0.1 +counter_value: diff +"#, + ); + diff_export.emit_counter(&name, &values); + let buf = diff_rx.try_recv().unwrap(); + assert!(std::str::from_utf8(&buf).unwrap().contains(" 7 ")); + } + + #[test] + fn stream_export_concatenates_pieces() { + let export = GraphitePlaintextStreamExport::default(); + let mut buf = Vec::new(); + let n = export.serialize(&[b"a\n".to_vec(), b"b\n".to_vec()], &mut buf); + assert_eq!(n, 2); + assert_eq!(buf, b"a\nb\n"); + } +} diff --git a/vey-statsd/src/import/statsd/parser/line.rs b/vey-statsd/src/import/statsd/parser/line.rs index 1e9768080..734c97af9 100644 --- a/vey-statsd/src/import/statsd/parser/line.rs +++ b/vey-statsd/src/import/statsd/parser/line.rs @@ -332,5 +332,67 @@ mod tests { assert!(LineParser::new(b"gorets:1|c|@0").parse().is_err()); assert!(LineParser::new(b"gorets:1|c|@1.5").parse().is_err()); assert!(LineParser::new(b"gorets:1|c|@").parse().is_err()); + assert!(LineParser::new(b"gorets:1|c|@-0.5").parse().is_err()); + } + + #[test] + fn sample_rate_one_is_identity() { + let mut iter = LineParser::new(b"gorets:5|c|@1").parse().unwrap(); + let r = iter.next().unwrap().unwrap(); + assert_eq!(r.value, MetricValue::Unsigned(5)); + } + + #[test] + fn parse_errors() { + assert!(matches!( + LineParser::new(b"").parse(), + Err(StatsdParseError::NoName) + )); + assert!(matches!( + LineParser::new(b"gorets:1").parse(), + Err(StatsdParseError::NoType) + )); + assert!(matches!( + LineParser::new(b"gorets:1|ms").parse(), + Err(StatsdParseError::UnsupportedType) + )); + assert!(matches!( + LineParser::new(b"gorets|c").parse(), + Err(StatsdParseError::NoValue) + )); + assert!(matches!( + LineParser::new(b":1|c").parse(), + Err(StatsdParseError::NoName) + )); + assert!(matches!( + LineParser::new(b"gorets:|c").parse(), + Err(StatsdParseError::NoValue) + )); + } + + #[test] + fn skips_empty_multi_values() { + let mut iter = LineParser::new(b"multi:1::2|c").parse().unwrap(); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(1) + ); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(2) + ); + assert!(iter.next().is_none()); + } + + #[test] + fn scale_counter_preserves_double_when_needed() { + assert_eq!( + scale_counter_by_sample_rate(MetricValue::Unsigned(1), 0.3), + MetricValue::Double(1.0 / 0.3) + ); + assert_eq!( + scale_counter_by_sample_rate(MetricValue::Unsigned(3), 0.5), + MetricValue::Unsigned(6) + ); } } diff --git a/vey-statsd/src/import/statsd/parser/mod.rs b/vey-statsd/src/import/statsd/parser/mod.rs index 396638456..3c926cd28 100644 --- a/vey-statsd/src/import/statsd/parser/mod.rs +++ b/vey-statsd/src/import/statsd/parser/mod.rs @@ -110,4 +110,41 @@ mod tests { assert!(iter.next().is_none()); } + + #[test] + fn trailing_line_without_newline() { + let mut iter = StatsdRecordVisitor::new(b"a:1|c\nb:2|g"); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(1) + ); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(2) + ); + assert!(iter.next().is_none()); + } + + #[test] + fn yields_error_then_continues() { + let mut iter = StatsdRecordVisitor::new(b"bad\ngood:1|c\n"); + assert!(iter.next().unwrap().is_err()); + let ok = iter.next().unwrap().unwrap(); + assert_eq!(ok.value, MetricValue::Unsigned(1)); + assert!(iter.next().is_none()); + } + + #[test] + fn multi_value_line_across_visitor() { + let mut iter = StatsdRecordVisitor::new(b"m:1:2|c\n"); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(1) + ); + assert_eq!( + iter.next().unwrap().unwrap().value, + MetricValue::Unsigned(2) + ); + assert!(iter.next().is_none()); + } } diff --git a/vey-statsd/src/runtime/export/aggregate/mod.rs b/vey-statsd/src/runtime/export/aggregate/mod.rs index 6b5adf778..861dae54b 100644 --- a/vey-statsd/src/runtime/export/aggregate/mod.rs +++ b/vey-statsd/src/runtime/export/aggregate/mod.rs @@ -262,4 +262,31 @@ mod tests { assert_eq!(counter_entry.sum, MetricValue::Signed(25)); assert_eq!(counter_entry.diff, MetricValue::Signed(10)); } + + #[test] + fn gauge_overwrites_same_tags() { + let (_tx, rx) = mpsc::unbounded_channel(); + let exporter = TestExporter { + counters: AHashMap::default(), + }; + let mut runtime = AggregateExportRuntime::new(exporter, rx); + let name = Arc::new(MetricName::parse("g").unwrap()); + let tag_map = Arc::new(MetricTagMap::default()); + + runtime.add_record(MetricRecord { + name: name.clone(), + tag_map: tag_map.clone(), + r#type: MetricType::Gauge, + value: MetricValue::Unsigned(1), + }); + runtime.add_record(MetricRecord { + name: name.clone(), + tag_map: tag_map.clone(), + r#type: MetricType::Gauge, + value: MetricValue::Unsigned(9), + }); + + let gauge = &runtime.gauge.get(&name).unwrap().inner[&tag_map]; + assert_eq!(gauge.value, MetricValue::Unsigned(9)); + } } diff --git a/vey-statsd/src/types/name.rs b/vey-statsd/src/types/name.rs index 579071939..9f67346ca 100644 --- a/vey-statsd/src/types/name.rs +++ b/vey-statsd/src/types/name.rs @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: Apache-2.0 * SPDX-FileCopyrightText: 2025 ByteDance and/or its affiliates. + * SPDX-FileCopyrightText: 2026 VEY-OSS Developers. */ use std::collections::VecDeque; @@ -109,4 +110,25 @@ mod tests { "vey.bar.foo.counter" ); } + + #[test] + fn parse_dotted_and_display_slash() { + let name = MetricName::parse("a.b.c").unwrap(); + assert_eq!(name.display('/').to_string(), "a/b/c"); + } + + #[test] + fn parse_yaml_string_and_array() { + let name = MetricName::parse_yaml(&Yaml::String("x.y".to_string())).unwrap(); + assert_eq!(name.display('.').to_string(), "x.y"); + + let arr = Yaml::Array(vec![ + Yaml::String("n1".to_string()), + Yaml::String("n2".to_string()), + ]); + let name = MetricName::parse_yaml(&arr).unwrap(); + assert_eq!(name.display('.').to_string(), "n1.n2"); + + assert!(MetricName::parse_yaml(&Yaml::Boolean(true)).is_err()); + } } diff --git a/vey-statsd/src/types/value.rs b/vey-statsd/src/types/value.rs index e572a169e..555d3ec6d 100644 --- a/vey-statsd/src/types/value.rs +++ b/vey-statsd/src/types/value.rs @@ -159,4 +159,67 @@ mod tests { let v = MetricValue::Double(1.0); assert_eq!(v.display_influxdb().to_string(), "1.0"); } + + #[test] + fn from_str_variants() { + assert_eq!( + MetricValue::from_str("42").unwrap(), + MetricValue::Unsigned(42) + ); + assert_eq!( + MetricValue::from_str("-7").unwrap(), + MetricValue::Signed(-7) + ); + assert_eq!( + MetricValue::from_str("1.5").unwrap(), + MetricValue::Double(1.5) + ); + assert_eq!( + MetricValue::from_str("-0.25").unwrap(), + MetricValue::Double(-0.25) + ); + assert!(MetricValue::from_str("").is_err()); + assert!(MetricValue::from_str("abc").is_err()); + } + + #[test] + fn display_and_as_f64() { + assert_eq!(MetricValue::Unsigned(9).to_string(), "9"); + assert_eq!(MetricValue::Signed(-3).to_string(), "-3"); + assert_eq!(MetricValue::Unsigned(3).as_f64(), 3.0); + assert_eq!(MetricValue::Signed(-3).as_f64(), -3.0); + assert_eq!(MetricValue::Double(1.25).as_f64(), 1.25); + } + + #[test] + fn add_and_add_assign() { + assert_eq!( + MetricValue::Unsigned(1) + MetricValue::Unsigned(2), + MetricValue::Unsigned(3) + ); + assert_eq!( + MetricValue::Signed(-1) + MetricValue::Unsigned(5), + MetricValue::Signed(4) + ); + assert_eq!( + MetricValue::Double(1.0) + MetricValue::Signed(2), + MetricValue::Double(3.0) + ); + let mut v = MetricValue::Unsigned(1); + v += MetricValue::Unsigned(4); + assert_eq!(v, MetricValue::Unsigned(5)); + } + + #[test] + fn as_json_number() { + assert_eq!( + MetricValue::Unsigned(8).as_json_number().unwrap().as_u64(), + Some(8) + ); + assert_eq!( + MetricValue::Signed(-2).as_json_number().unwrap().as_i64(), + Some(-2) + ); + assert!(MetricValue::Double(f64::NAN).as_json_number().is_none()); + } }