diff --git a/Makefile b/Makefile index bfa5789..87b1108 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,7 @@ vet: test: go test -race -v ./internal/... + cd envd-rs && cargo test test-integration: go test -race -v -tags=integration ./tests/integration/... diff --git a/envd-rs/Cargo.lock b/envd-rs/Cargo.lock index 2e173d6..5de60fc 100644 --- a/envd-rs/Cargo.lock +++ b/envd-rs/Cargo.lock @@ -543,6 +543,7 @@ dependencies = [ "sha2", "subtle", "sysinfo", + "tempfile", "tokio", "tokio-util", "tower", diff --git a/envd-rs/Cargo.toml b/envd-rs/Cargo.toml index 55947e3..70f668d 100644 --- a/envd-rs/Cargo.toml +++ b/envd-rs/Cargo.toml @@ -72,6 +72,9 @@ buffa = "0.3" async-stream = "0.3.6" mime_guess = "2" +[dev-dependencies] +tempfile = "3" + [build-dependencies] connectrpc-build = "0.3" diff --git a/envd-rs/src/auth/signing.rs b/envd-rs/src/auth/signing.rs index 62ea001..348d0c4 100644 --- a/envd-rs/src/auth/signing.rs +++ b/envd-rs/src/auth/signing.rs @@ -83,3 +83,128 @@ pub fn validate_signing( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_token(val: &[u8]) -> SecureToken { + let t = SecureToken::new(); + t.set(val).unwrap(); + t + } + + fn far_future() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 3600 + } + + #[test] + fn generate_starts_with_v1() { + let token = test_token(b"secret"); + let sig = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap(); + assert!(sig.starts_with("v1_")); + } + + #[test] + fn generate_deterministic() { + let token = test_token(b"secret"); + let s1 = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap(); + let s2 = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap(); + assert_eq!(s1, s2); + } + + #[test] + fn generate_with_expiration_differs() { + let token = test_token(b"secret"); + let without = generate_signature(&token, "/f", "u", READ_OPERATION, None).unwrap(); + let with = generate_signature(&token, "/f", "u", READ_OPERATION, Some(9999)).unwrap(); + assert_ne!(without, with); + } + + #[test] + fn generate_unset_token_errors() { + let token = SecureToken::new(); + assert!(generate_signature(&token, "/f", "u", READ_OPERATION, None).is_err()); + } + + #[test] + fn validate_no_token_set_passes() { + let token = SecureToken::new(); + assert!(validate_signing(&token, None, None, None, "root", "/f", READ_OPERATION).is_ok()); + } + + #[test] + fn validate_correct_header_token() { + let token = test_token(b"secret"); + assert!(validate_signing(&token, Some("secret"), None, None, "root", "/f", READ_OPERATION).is_ok()); + } + + #[test] + fn validate_wrong_header_token() { + let token = test_token(b"secret"); + let result = validate_signing(&token, Some("wrong"), None, None, "root", "/f", READ_OPERATION); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not match")); + } + + #[test] + fn validate_valid_signature() { + let token = test_token(b"secret"); + let exp = far_future(); + let sig = generate_signature(&token, "/file", "root", READ_OPERATION, Some(exp)).unwrap(); + assert!(validate_signing(&token, None, Some(&sig), Some(exp), "root", "/file", READ_OPERATION).is_ok()); + } + + #[test] + fn validate_invalid_signature() { + let token = test_token(b"secret"); + let result = validate_signing(&token, None, Some("v1_bad"), Some(far_future()), "root", "/f", READ_OPERATION); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("invalid signature")); + } + + #[test] + fn validate_expired_signature() { + let token = test_token(b"secret"); + let expired: i64 = 1_000_000; + let sig = generate_signature(&token, "/f", "root", READ_OPERATION, Some(expired)).unwrap(); + let result = validate_signing(&token, None, Some(&sig), Some(expired), "root", "/f", READ_OPERATION); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("expired")); + } + + #[test] + fn validate_missing_signature() { + let token = test_token(b"secret"); + let result = validate_signing(&token, None, None, None, "root", "/f", READ_OPERATION); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("missing signature")); + } + + #[test] + fn validate_empty_header_token_falls_through_to_signature() { + let token = test_token(b"secret"); + let result = validate_signing(&token, Some(""), None, None, "root", "/f", READ_OPERATION); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("missing signature")); + } + + #[test] + fn validate_valid_signature_no_expiration() { + let token = test_token(b"secret"); + let sig = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap(); + assert!(validate_signing(&token, None, Some(&sig), None, "root", "/file", READ_OPERATION).is_ok()); + } + + #[test] + fn different_operations_produce_different_signatures() { + let token = test_token(b"secret"); + let r = generate_signature(&token, "/f", "root", READ_OPERATION, None).unwrap(); + let w = generate_signature(&token, "/f", "root", WRITE_OPERATION, None).unwrap(); + assert_ne!(r, w); + } +} diff --git a/envd-rs/src/auth/token.rs b/envd-rs/src/auth/token.rs index 621f797..d521b4b 100644 --- a/envd-rs/src/auth/token.rs +++ b/envd-rs/src/auth/token.rs @@ -125,3 +125,132 @@ impl SecureToken { Ok(token) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_unset() { + let t = SecureToken::new(); + assert!(!t.is_set()); + assert!(!t.equals("anything")); + } + + #[test] + fn set_and_equals() { + let t = SecureToken::new(); + t.set(b"secret").unwrap(); + assert!(t.is_set()); + assert!(t.equals("secret")); + assert!(!t.equals("wrong")); + } + + #[test] + fn set_empty_errors() { + let t = SecureToken::new(); + assert!(t.set(b"").is_err()); + assert!(!t.is_set()); + } + + #[test] + fn set_overwrites_previous() { + let t = SecureToken::new(); + t.set(b"first").unwrap(); + t.set(b"second").unwrap(); + assert!(!t.equals("first")); + assert!(t.equals("second")); + } + + #[test] + fn destroy_clears() { + let t = SecureToken::new(); + t.set(b"secret").unwrap(); + t.destroy(); + assert!(!t.is_set()); + assert!(!t.equals("secret")); + } + + #[test] + fn bytes_returns_copy() { + let t = SecureToken::new(); + assert!(t.bytes().is_none()); + t.set(b"hello").unwrap(); + assert_eq!(t.bytes().unwrap(), b"hello"); + } + + #[test] + fn take_from_transfers_and_clears_source() { + let src = SecureToken::new(); + src.set(b"token").unwrap(); + let dst = SecureToken::new(); + dst.take_from(&src); + assert!(!src.is_set()); + assert!(dst.equals("token")); + } + + #[test] + fn take_from_overwrites_existing() { + let src = SecureToken::new(); + src.set(b"new").unwrap(); + let dst = SecureToken::new(); + dst.set(b"old").unwrap(); + dst.take_from(&src); + assert!(dst.equals("new")); + assert!(!dst.equals("old")); + } + + #[test] + fn equals_secure_matching() { + let a = SecureToken::new(); + a.set(b"same").unwrap(); + let b = SecureToken::new(); + b.set(b"same").unwrap(); + assert!(a.equals_secure(&b)); + } + + #[test] + fn equals_secure_different() { + let a = SecureToken::new(); + a.set(b"one").unwrap(); + let b = SecureToken::new(); + b.set(b"two").unwrap(); + assert!(!a.equals_secure(&b)); + } + + #[test] + fn equals_secure_unset() { + let a = SecureToken::new(); + let b = SecureToken::new(); + assert!(!a.equals_secure(&b)); + } + + #[test] + fn from_json_bytes_valid() { + let mut data = b"\"mysecret\"".to_vec(); + let t = SecureToken::from_json_bytes(&mut data).unwrap(); + assert!(t.equals("mysecret")); + assert!(data.iter().all(|&b| b == 0)); + } + + #[test] + fn from_json_bytes_rejects_missing_quotes() { + let mut data = b"noquotes".to_vec(); + assert!(SecureToken::from_json_bytes(&mut data).is_err()); + assert!(data.iter().all(|&b| b == 0)); + } + + #[test] + fn from_json_bytes_rejects_escape_sequences() { + let mut data = b"\"has\\nescapes\"".to_vec(); + assert!(SecureToken::from_json_bytes(&mut data).is_err()); + assert!(data.iter().all(|&b| b == 0)); + } + + #[test] + fn from_json_bytes_rejects_empty_content() { + let mut data = b"\"\"".to_vec(); + assert!(SecureToken::from_json_bytes(&mut data).is_err()); + assert!(data.iter().all(|&b| b == 0)); + } +} diff --git a/envd-rs/src/conntracker.rs b/envd-rs/src/conntracker.rs index 8ec4d39..15c974c 100644 --- a/envd-rs/src/conntracker.rs +++ b/envd-rs/src/conntracker.rs @@ -76,4 +76,125 @@ impl ConnTracker { pub fn keepalives_enabled(&self) -> bool { self.inner.lock().unwrap().keepalives_enabled } + + #[cfg(test)] + fn active_count(&self) -> usize { + self.inner.lock().unwrap().active.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_assigns_sequential_ids() { + let ct = ConnTracker::new(); + assert_eq!(ct.register_connection(), 0); + assert_eq!(ct.register_connection(), 1); + assert_eq!(ct.register_connection(), 2); + } + + #[test] + fn remove_clears_active() { + let ct = ConnTracker::new(); + let id = ct.register_connection(); + assert_eq!(ct.active_count(), 1); + ct.remove_connection(id); + assert_eq!(ct.active_count(), 0); + } + + #[test] + fn remove_nonexistent_is_noop() { + let ct = ConnTracker::new(); + ct.remove_connection(999); + assert_eq!(ct.active_count(), 0); + } + + #[test] + fn prepare_disables_keepalives() { + let ct = ConnTracker::new(); + assert!(ct.keepalives_enabled()); + ct.register_connection(); + ct.prepare_for_snapshot(); + assert!(!ct.keepalives_enabled()); + } + + #[test] + fn restore_removes_zombies_and_reenables_keepalives() { + let ct = ConnTracker::new(); + let id0 = ct.register_connection(); + let id1 = ct.register_connection(); + ct.prepare_for_snapshot(); + ct.restore_after_snapshot(); + assert!(ct.keepalives_enabled()); + // Both pre-snapshot connections removed as zombies + assert_eq!(ct.active_count(), 0); + // IDs don't matter anymore, but remove shouldn't panic + ct.remove_connection(id0); + ct.remove_connection(id1); + } + + #[test] + fn restore_without_prepare_is_noop() { + let ct = ConnTracker::new(); + let _id = ct.register_connection(); + ct.restore_after_snapshot(); + assert!(ct.keepalives_enabled()); + assert_eq!(ct.active_count(), 1); + } + + #[test] + fn connection_closed_before_restore_not_zombie() { + let ct = ConnTracker::new(); + let id0 = ct.register_connection(); + let _id1 = ct.register_connection(); + ct.prepare_for_snapshot(); + // Close id0 during snapshot window + ct.remove_connection(id0); + assert_eq!(ct.active_count(), 1); + ct.restore_after_snapshot(); + // id1 was zombie (still active at restore), id0 already gone + assert_eq!(ct.active_count(), 0); + } + + #[test] + fn post_snapshot_connection_survives_restore() { + let ct = ConnTracker::new(); + ct.register_connection(); + ct.prepare_for_snapshot(); + // New connection after snapshot + let _post = ct.register_connection(); + ct.restore_after_snapshot(); + // Pre-snapshot connection removed, post-snapshot survives + assert_eq!(ct.active_count(), 1); + } + + #[test] + fn full_lifecycle() { + let ct = ConnTracker::new(); + let _a = ct.register_connection(); + let b = ct.register_connection(); + let _c = ct.register_connection(); + assert_eq!(ct.active_count(), 3); + assert!(ct.keepalives_enabled()); + + ct.prepare_for_snapshot(); + assert!(!ct.keepalives_enabled()); + + let d = ct.register_connection(); + ct.remove_connection(b); + + ct.restore_after_snapshot(); + assert!(ct.keepalives_enabled()); + // a and c were zombies, b removed before restore, d is post-snapshot + assert_eq!(ct.active_count(), 1); + ct.remove_connection(d); + assert_eq!(ct.active_count(), 0); + + // Can reuse tracker after restore + let e = ct.register_connection(); + assert_eq!(ct.active_count(), 1); + assert!(e > d); + } } diff --git a/envd-rs/src/crypto/hmac_sha256.rs b/envd-rs/src/crypto/hmac_sha256.rs index 2f51afe..0cc868a 100644 --- a/envd-rs/src/crypto/hmac_sha256.rs +++ b/envd-rs/src/crypto/hmac_sha256.rs @@ -15,8 +15,29 @@ mod tests { use super::*; #[test] - fn test_hmac_sha256() { - let result = compute(b"key", b"message"); - assert_eq!(result.len(), 64); // SHA-256 hex = 64 chars + fn rfc4231_tc1() { + let key = &[0x0b; 20]; + let data = b"Hi There"; + assert_eq!( + compute(key, data), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + } + + #[test] + fn rfc4231_tc2() { + let key = b"Jefe"; + let data = b"what do ya want for nothing?"; + assert_eq!( + compute(key, data), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + } + + #[test] + fn output_is_64_hex_chars() { + let result = compute(b"key", b"data"); + assert_eq!(result.len(), 64); + assert!(result.chars().all(|c| c.is_ascii_hexdigit())); } } diff --git a/envd-rs/src/crypto/sha256.rs b/envd-rs/src/crypto/sha256.rs index b87034d..353c3cb 100644 --- a/envd-rs/src/crypto/sha256.rs +++ b/envd-rs/src/crypto/sha256.rs @@ -17,17 +17,38 @@ pub fn hash_without_prefix(data: &[u8]) -> String { mod tests { use super::*; + const VECTORS: &[(&[u8], &str)] = &[ + (b"", "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"), + (b"abc", "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"), + (b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE"), + ]; + #[test] - fn test_hash_format() { - let result = hash(b"test"); - assert!(result.starts_with("$sha256$")); - assert!(!result.contains('=')); + fn known_answer_with_prefix() { + for (input, expected_b64) in VECTORS { + let result = hash(input); + assert_eq!(result, format!("$sha256${expected_b64}"), "input: {:?}", String::from_utf8_lossy(input)); + } } #[test] - fn test_hash_without_prefix() { - let result = hash_without_prefix(b"test"); - assert!(!result.starts_with("$sha256$")); - assert!(!result.contains('=')); + fn known_answer_without_prefix() { + for (input, expected_b64) in VECTORS { + let result = hash_without_prefix(input); + assert_eq!(result, *expected_b64, "input: {:?}", String::from_utf8_lossy(input)); + } + } + + #[test] + fn no_base64_padding() { + for (input, _) in VECTORS { + assert!(!hash(input).contains('=')); + assert!(!hash_without_prefix(input).contains('=')); + } + } + + #[test] + fn deterministic() { + assert_eq!(hash(b"test"), hash(b"test")); } } diff --git a/envd-rs/src/crypto/sha512.rs b/envd-rs/src/crypto/sha512.rs index 353100e..747ed11 100644 --- a/envd-rs/src/crypto/sha512.rs +++ b/envd-rs/src/crypto/sha512.rs @@ -14,11 +14,30 @@ pub fn hash_access_token_bytes(token: &[u8]) -> String { mod tests { use super::*; + const VECTORS: &[(&str, &str)] = &[ + ("", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"), + ("abc", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"), + ("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445"), + ]; + #[test] - fn test_hash_access_token() { - let h1 = hash_access_token("test"); - let h2 = hash_access_token_bytes(b"test"); - assert_eq!(h1, h2); - assert_eq!(h1.len(), 128); // SHA-512 hex = 128 chars + fn known_answer() { + for (input, expected) in VECTORS { + assert_eq!(hash_access_token(input), *expected, "input: {input:?}"); + } + } + + #[test] + fn str_and_bytes_agree() { + for (input, _) in VECTORS { + assert_eq!(hash_access_token(input), hash_access_token_bytes(input.as_bytes())); + } + } + + #[test] + fn output_is_lowercase_hex_128_chars() { + let h = hash_access_token("anything"); + assert_eq!(h.len(), 128); + assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); } } diff --git a/envd-rs/src/execcontext.rs b/envd-rs/src/execcontext.rs index e3baa43..6ad29e6 100644 --- a/envd-rs/src/execcontext.rs +++ b/envd-rs/src/execcontext.rs @@ -55,3 +55,64 @@ pub fn resolve_default_username<'a>( } Err("username not provided") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workdir_explicit_overrides_default() { + assert_eq!(resolve_default_workdir("/explicit", Some("/default")), "/explicit"); + } + + #[test] + fn workdir_empty_uses_default() { + assert_eq!(resolve_default_workdir("", Some("/default")), "/default"); + } + + #[test] + fn workdir_empty_no_default_returns_empty() { + assert_eq!(resolve_default_workdir("", None), ""); + } + + #[test] + fn workdir_explicit_ignores_none_default() { + assert_eq!(resolve_default_workdir("/explicit", None), "/explicit"); + } + + #[test] + fn username_explicit_returns_explicit() { + assert_eq!(resolve_default_username(Some("root"), "wrenn").unwrap(), "root"); + } + + #[test] + fn username_none_uses_default() { + assert_eq!(resolve_default_username(None, "wrenn").unwrap(), "wrenn"); + } + + #[test] + fn username_none_empty_default_errors() { + assert!(resolve_default_username(None, "").is_err()); + } + + #[test] + fn username_some_overrides_empty_default() { + assert_eq!(resolve_default_username(Some("root"), "").unwrap(), "root"); + } + + #[test] + fn defaults_user_set_and_get() { + let d = Defaults::new("initial"); + assert_eq!(d.user(), "initial"); + d.set_user("changed".into()); + assert_eq!(d.user(), "changed"); + } + + #[test] + fn defaults_workdir_initially_none() { + let d = Defaults::new("user"); + assert!(d.workdir().is_none()); + d.set_workdir(Some("/home".into())); + assert_eq!(d.workdir().unwrap(), "/home"); + } +} diff --git a/envd-rs/src/http/encoding.rs b/envd-rs/src/http/encoding.rs index 02f15b6..d573d04 100644 --- a/envd-rs/src/http/encoding.rs +++ b/envd-rs/src/http/encoding.rs @@ -145,3 +145,192 @@ pub fn parse_content_encoding(r: &Request) -> Result<&'static str, String> Err(format!("unsupported Content-Encoding: {header}, supported: {SUPPORTED_ENCODINGS:?}")) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::Request; + + fn req_with_accept(v: &str) -> Request<()> { + Request::builder() + .header("accept-encoding", v) + .body(()) + .unwrap() + } + + fn req_with_content(v: &str) -> Request<()> { + Request::builder() + .header("content-encoding", v) + .body(()) + .unwrap() + } + + fn req_no_headers() -> Request<()> { + Request::builder().body(()).unwrap() + } + + // parse_encoding_with_quality + + #[test] + fn encoding_quality_default_1() { + let eq = parse_encoding_with_quality("gzip"); + assert_eq!(eq.encoding, "gzip"); + assert_eq!(eq.quality, 1.0); + } + + #[test] + fn encoding_quality_explicit() { + let eq = parse_encoding_with_quality("gzip;q=0.8"); + assert_eq!(eq.encoding, "gzip"); + assert_eq!(eq.quality, 0.8); + } + + #[test] + fn encoding_quality_case_insensitive() { + let eq = parse_encoding_with_quality("GZIP;Q=0.5"); + assert_eq!(eq.encoding, "gzip"); + assert_eq!(eq.quality, 0.5); + } + + #[test] + fn encoding_quality_zero() { + let eq = parse_encoding_with_quality("gzip;q=0"); + assert_eq!(eq.quality, 0.0); + } + + #[test] + fn encoding_quality_whitespace_trimmed() { + let eq = parse_encoding_with_quality(" gzip ; q=0.9 "); + assert_eq!(eq.encoding, "gzip"); + assert_eq!(eq.quality, 0.9); + } + + // parse_accept_encoding_header + + #[test] + fn accept_header_empty() { + let (encs, rejected) = parse_accept_encoding_header(""); + assert!(encs.is_empty()); + assert!(!rejected); + } + + #[test] + fn accept_header_identity_q0_rejects() { + let (_, rejected) = parse_accept_encoding_header("identity;q=0"); + assert!(rejected); + } + + #[test] + fn accept_header_wildcard_q0_rejects_identity() { + let (_, rejected) = parse_accept_encoding_header("*;q=0"); + assert!(rejected); + } + + #[test] + fn accept_header_wildcard_q0_but_identity_explicit_accepted() { + let (_, rejected) = parse_accept_encoding_header("*;q=0, identity"); + assert!(!rejected); + } + + // parse_accept_encoding (full) + + #[test] + fn accept_encoding_no_header_returns_identity() { + assert_eq!(parse_accept_encoding(&req_no_headers()).unwrap(), "identity"); + } + + #[test] + fn accept_encoding_gzip() { + assert_eq!(parse_accept_encoding(&req_with_accept("gzip")).unwrap(), "gzip"); + } + + #[test] + fn accept_encoding_identity_explicit() { + assert_eq!(parse_accept_encoding(&req_with_accept("identity")).unwrap(), "identity"); + } + + #[test] + fn accept_encoding_gzip_higher_quality() { + assert_eq!( + parse_accept_encoding(&req_with_accept("identity;q=0.1, gzip;q=0.9")).unwrap(), + "gzip" + ); + } + + #[test] + fn accept_encoding_wildcard_returns_identity() { + assert_eq!(parse_accept_encoding(&req_with_accept("*")).unwrap(), "identity"); + } + + #[test] + fn accept_encoding_wildcard_identity_rejected_returns_gzip() { + assert_eq!( + parse_accept_encoding(&req_with_accept("identity;q=0, *")).unwrap(), + "gzip" + ); + } + + #[test] + fn accept_encoding_all_rejected_errors() { + assert!(parse_accept_encoding(&req_with_accept("identity;q=0, *;q=0")).is_err()); + } + + #[test] + fn accept_encoding_unsupported_only_falls_to_identity() { + assert_eq!(parse_accept_encoding(&req_with_accept("br")).unwrap(), "identity"); + } + + // is_identity_acceptable + + #[test] + fn identity_acceptable_no_header() { + assert!(is_identity_acceptable(&req_no_headers())); + } + + #[test] + fn identity_acceptable_gzip_only() { + assert!(is_identity_acceptable(&req_with_accept("gzip"))); + } + + #[test] + fn identity_not_acceptable_identity_q0() { + assert!(!is_identity_acceptable(&req_with_accept("identity;q=0"))); + } + + #[test] + fn identity_not_acceptable_wildcard_q0() { + assert!(!is_identity_acceptable(&req_with_accept("*;q=0"))); + } + + #[test] + fn identity_acceptable_wildcard_q0_but_identity_explicit() { + assert!(is_identity_acceptable(&req_with_accept("*;q=0, identity"))); + } + + // parse_content_encoding + + #[test] + fn content_encoding_empty_returns_identity() { + assert_eq!(parse_content_encoding(&req_no_headers()).unwrap(), "identity"); + } + + #[test] + fn content_encoding_gzip() { + assert_eq!(parse_content_encoding(&req_with_content("gzip")).unwrap(), "gzip"); + } + + #[test] + fn content_encoding_identity_explicit() { + assert_eq!(parse_content_encoding(&req_with_content("identity")).unwrap(), "identity"); + } + + #[test] + fn content_encoding_unsupported_errors() { + assert!(parse_content_encoding(&req_with_content("br")).is_err()); + } + + #[test] + fn content_encoding_case_insensitive() { + assert_eq!(parse_content_encoding(&req_with_content("GZIP")).unwrap(), "gzip"); + } +} diff --git a/envd-rs/src/permissions/path.rs b/envd-rs/src/permissions/path.rs index 80a5a4e..cf6a1c2 100644 --- a/envd-rs/src/permissions/path.rs +++ b/envd-rs/src/permissions/path.rs @@ -70,3 +70,115 @@ pub fn ensure_dirs(path: &str, uid: Uid, gid: Gid) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + // expand_tilde + + #[test] + fn tilde_empty_passthrough() { + assert_eq!(expand_tilde("", "/home/u").unwrap(), ""); + } + + #[test] + fn tilde_no_tilde_passthrough() { + assert_eq!(expand_tilde("/absolute", "/home/u").unwrap(), "/absolute"); + } + + #[test] + fn tilde_bare() { + assert_eq!(expand_tilde("~", "/home/user").unwrap(), "/home/user"); + } + + #[test] + fn tilde_slash_path() { + assert_eq!(expand_tilde("~/docs", "/home/user").unwrap(), "/home/user/docs"); + } + + #[test] + fn tilde_nested() { + assert_eq!(expand_tilde("~/a/b/c", "/h").unwrap(), "/h/a/b/c"); + } + + #[test] + fn tilde_other_user_errors() { + assert!(expand_tilde("~bob/foo", "/home/user").is_err()); + } + + #[test] + fn tilde_relative_no_tilde() { + assert_eq!(expand_tilde("relative/path", "/home/u").unwrap(), "relative/path"); + } + + // expand_and_resolve + + #[test] + fn resolve_absolute_passthrough() { + assert_eq!(expand_and_resolve("/abs/path", "/home", None).unwrap(), "/abs/path"); + } + + #[test] + fn resolve_empty_uses_default() { + assert_eq!(expand_and_resolve("", "/home", Some("/default")).unwrap(), "/default"); + } + + #[test] + fn resolve_empty_no_default_falls_back_to_home() { + // Empty path with no default → joins "" with home_dir → returns home_dir + let result = expand_and_resolve("", "/home", None).unwrap(); + assert_eq!(result, "/home"); + } + + #[test] + fn resolve_tilde_expands() { + assert_eq!(expand_and_resolve("~/dir", "/home/u", None).unwrap(), "/home/u/dir"); + } + + #[test] + fn resolve_relative_joins_home() { + let result = expand_and_resolve("subdir", "/tmp", None).unwrap(); + // Relative path joined with home and canonicalized (or raw join on missing) + assert!(result.starts_with("/tmp")); + assert!(result.contains("subdir")); + } + + #[test] + fn resolve_tilde_other_user_errors() { + assert!(expand_and_resolve("~bob", "/home/u", None).is_err()); + } + + // ensure_dirs + + #[test] + fn ensure_dirs_creates_nested() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("a/b/c"); + let uid = nix::unistd::getuid(); + let gid = nix::unistd::getgid(); + ensure_dirs(path.to_str().unwrap(), uid, gid).unwrap(); + assert!(path.is_dir()); + } + + #[test] + fn ensure_dirs_existing_is_ok() { + let tmp = tempfile::TempDir::new().unwrap(); + let uid = nix::unistd::getuid(); + let gid = nix::unistd::getgid(); + ensure_dirs(tmp.path().to_str().unwrap(), uid, gid).unwrap(); + } + + #[test] + fn ensure_dirs_file_in_path_errors() { + let tmp = tempfile::TempDir::new().unwrap(); + let file_path = tmp.path().join("afile"); + std::fs::write(&file_path, "").unwrap(); + let nested = file_path.join("subdir"); + let uid = nix::unistd::getuid(); + let gid = nix::unistd::getgid(); + let result = ensure_dirs(nested.to_str().unwrap(), uid, gid); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("path is a file")); + } +} diff --git a/envd-rs/src/port/conn.rs b/envd-rs/src/port/conn.rs index b256e84..8534bc2 100644 --- a/envd-rs/src/port/conn.rs +++ b/envd-rs/src/port/conn.rs @@ -110,3 +110,151 @@ fn parse_hex_addr(s: &str, family: u32) -> Option<(String, u32)> { Some((ip_str, port)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // tcp_state_name + + #[test] + fn state_all_known_codes() { + assert_eq!(tcp_state_name("01"), "ESTABLISHED"); + assert_eq!(tcp_state_name("02"), "SYN_SENT"); + assert_eq!(tcp_state_name("03"), "SYN_RECV"); + assert_eq!(tcp_state_name("04"), "FIN_WAIT1"); + assert_eq!(tcp_state_name("05"), "FIN_WAIT2"); + assert_eq!(tcp_state_name("06"), "TIME_WAIT"); + assert_eq!(tcp_state_name("07"), "CLOSE"); + assert_eq!(tcp_state_name("08"), "CLOSE_WAIT"); + assert_eq!(tcp_state_name("09"), "LAST_ACK"); + assert_eq!(tcp_state_name("0A"), "LISTEN"); + assert_eq!(tcp_state_name("0B"), "CLOSING"); + } + + #[test] + fn state_unknown_code() { + assert_eq!(tcp_state_name("FF"), "UNKNOWN"); + assert_eq!(tcp_state_name("00"), "UNKNOWN"); + } + + // parse_hex_addr + + #[test] + fn ipv4_localhost() { + let (ip, port) = parse_hex_addr("0100007F:0050", libc::AF_INET as u32).unwrap(); + assert_eq!(ip, "127.0.0.1"); + assert_eq!(port, 80); + } + + #[test] + fn ipv4_any() { + let (ip, port) = parse_hex_addr("00000000:0035", libc::AF_INET as u32).unwrap(); + assert_eq!(ip, "0.0.0.0"); + assert_eq!(port, 53); + } + + #[test] + fn ipv4_real_address() { + // 192.168.1.1 in little-endian = 0101A8C0 + let (ip, port) = parse_hex_addr("0101A8C0:01BB", libc::AF_INET as u32).unwrap(); + assert_eq!(ip, "192.168.1.1"); + assert_eq!(port, 443); + } + + #[test] + fn ipv4_wrong_byte_count_returns_none() { + assert!(parse_hex_addr("0100:0050", libc::AF_INET as u32).is_none()); + } + + #[test] + fn invalid_hex_returns_none() { + assert!(parse_hex_addr("ZZZZZZZZ:0050", libc::AF_INET as u32).is_none()); + } + + #[test] + fn no_colon_returns_none() { + assert!(parse_hex_addr("0100007F0050", libc::AF_INET as u32).is_none()); + } + + #[test] + fn ipv6_loopback() { + // ::1 in /proc/net/tcp6 format: 00000000000000000000000001000000 + let (ip, port) = parse_hex_addr( + "00000000000000000000000001000000:0035", + libc::AF_INET6 as u32, + ) + .unwrap(); + assert_eq!(ip, "::1"); + assert_eq!(port, 53); + } + + #[test] + fn ipv6_wrong_byte_count_returns_none() { + assert!(parse_hex_addr("0100007F:0050", libc::AF_INET6 as u32).is_none()); + } + + // parse_proc_net_tcp + + fn write_tcp_file(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn parse_empty_file() { + let f = write_tcp_file( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n", + ); + let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap(); + assert!(conns.is_empty()); + } + + #[test] + fn parse_single_entry() { + let content = "\ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 00000000\n"; + let f = write_tcp_file(content); + let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap(); + assert_eq!(conns.len(), 1); + assert_eq!(conns[0].local_ip, "127.0.0.1"); + assert_eq!(conns[0].local_port, 80); + assert_eq!(conns[0].status, "LISTEN"); + assert_eq!(conns[0].inode, 12345); + assert_eq!(conns[0].family, libc::AF_INET as u32); + } + + #[test] + fn parse_skips_malformed_rows() { + let content = "\ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 00000000 + bad line + 1: short\n"; + let f = write_tcp_file(content); + let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap(); + assert_eq!(conns.len(), 1); + } + + #[test] + fn parse_multiple_entries() { + let content = "\ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 100 1 00000000 + 1: 00000000:01BB 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 200 1 00000000\n"; + let f = write_tcp_file(content); + let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap(); + assert_eq!(conns.len(), 2); + assert_eq!(conns[0].local_port, 80); + assert_eq!(conns[1].local_port, 443); + } + + #[test] + fn parse_nonexistent_file_errors() { + assert!(parse_proc_net_tcp("/nonexistent/path", libc::AF_INET as u32).is_err()); + } +} diff --git a/envd-rs/src/rpc/entry.rs b/envd-rs/src/rpc/entry.rs index 9488268..e5c8bf1 100644 --- a/envd-rs/src/rpc/entry.rs +++ b/envd-rs/src/rpc/entry.rs @@ -140,3 +140,92 @@ fn format_permissions(mode: u32) -> String { } s } + +#[cfg(test)] +mod tests { + use super::*; + + // format_permissions + + #[test] + fn regular_file_755() { + assert_eq!(format_permissions(libc::S_IFREG | 0o755), "-rwxr-xr-x"); + } + + #[test] + fn directory_755() { + assert_eq!(format_permissions(libc::S_IFDIR | 0o755), "drwxr-xr-x"); + } + + #[test] + fn symlink_777() { + assert_eq!(format_permissions(libc::S_IFLNK | 0o777), "Lrwxrwxrwx"); + } + + #[test] + fn regular_file_000() { + assert_eq!(format_permissions(libc::S_IFREG | 0o000), "----------"); + } + + #[test] + fn regular_file_644() { + assert_eq!(format_permissions(libc::S_IFREG | 0o644), "-rw-r--r--"); + } + + #[test] + fn block_device() { + assert_eq!(format_permissions(libc::S_IFBLK | 0o660), "brw-rw----"); + } + + #[test] + fn char_device() { + assert_eq!(format_permissions(libc::S_IFCHR | 0o666), "crw-rw-rw-"); + } + + #[test] + fn fifo() { + assert_eq!(format_permissions(libc::S_IFIFO | 0o644), "prw-r--r--"); + } + + #[test] + fn socket() { + assert_eq!(format_permissions(libc::S_IFSOCK | 0o755), "Srwxr-xr-x"); + } + + #[test] + fn unknown_type() { + assert_eq!(format_permissions(0o755), "?rwxr-xr-x"); + } + + #[test] + fn setuid_in_mode_only_affects_lower_bits() { + // setuid (0o4755) — format_permissions masks with 0o777, so same as 0o755 + assert_eq!( + format_permissions(libc::S_IFREG | 0o4755), + format_permissions(libc::S_IFREG | 0o755), + ); + } + + #[test] + fn output_always_10_chars() { + for mode in [0o000, 0o777, 0o644, 0o755, 0o4755] { + assert_eq!(format_permissions(libc::S_IFREG | mode).len(), 10); + } + } + + // meta_to_file_type — needs real filesystem + + #[test] + fn meta_regular_file() { + let f = tempfile::NamedTempFile::new().unwrap(); + let meta = std::fs::metadata(f.path()).unwrap(); + assert_eq!(meta_to_file_type(&meta), FileType::FILE_TYPE_FILE); + } + + #[test] + fn meta_directory() { + let d = tempfile::TempDir::new().unwrap(); + let meta = std::fs::metadata(d.path()).unwrap(); + assert_eq!(meta_to_file_type(&meta), FileType::FILE_TYPE_DIRECTORY); + } +} diff --git a/envd-rs/src/util.rs b/envd-rs/src/util.rs index 2016eca..b8a0080 100644 --- a/envd-rs/src/util.rs +++ b/envd-rs/src/util.rs @@ -11,6 +11,10 @@ impl AtomicMax { } } + pub fn get(&self) -> i64 { + self.val.load(Ordering::Acquire) + } + /// Sets the stored value to `new` if `new` is strictly greater than /// the current value. Returns `true` if the value was updated. pub fn set_to_greater(&self, new: i64) -> bool { @@ -31,3 +35,68 @@ impl AtomicMax { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn initial_value_is_i64_min() { + let m = AtomicMax::new(); + assert_eq!(m.get(), i64::MIN); + } + + #[test] + fn updates_when_larger() { + let m = AtomicMax::new(); + assert!(m.set_to_greater(0)); + assert_eq!(m.get(), 0); + assert!(m.set_to_greater(100)); + assert_eq!(m.get(), 100); + } + + #[test] + fn returns_false_when_equal() { + let m = AtomicMax::new(); + m.set_to_greater(42); + assert!(!m.set_to_greater(42)); + assert_eq!(m.get(), 42); + } + + #[test] + fn returns_false_when_smaller() { + let m = AtomicMax::new(); + m.set_to_greater(100); + assert!(!m.set_to_greater(50)); + assert_eq!(m.get(), 100); + } + + #[test] + fn concurrent_convergence() { + let m = Arc::new(AtomicMax::new()); + let threads: Vec<_> = (0..8) + .map(|t| { + let m = Arc::clone(&m); + std::thread::spawn(move || { + for i in (t * 100)..((t + 1) * 100) { + m.set_to_greater(i); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + assert_eq!(m.get(), 799); + } + + #[test] + fn i64_max_boundary() { + let m = AtomicMax::new(); + assert!(m.set_to_greater(i64::MAX)); + assert!(!m.set_to_greater(i64::MAX)); + assert!(!m.set_to_greater(0)); + assert_eq!(m.get(), i64::MAX); + } +}