From 2254e6697986491b380b0af416a2fee9995c7549 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:33:29 -0700 Subject: [PATCH 1/2] harden: malformed-input hardening + non-panicking encoders + unit coverage across codec/tokens/config --- src/client/auth.rs | 68 +- src/client/config.rs | 206 +++++- src/client/config/ado_net.rs | 35 +- src/client/config/jdbc.rs | 2 +- src/client/connection.rs | 93 ++- src/client/tls.rs | 22 +- src/client/tls_stream/rustls_tls_stream.rs | 32 +- src/from_sql.rs | 139 ++++ src/sql_browser.rs | 26 +- src/sql_read_bytes.rs | 251 ++++++- src/tds/codec/column_data.rs | 440 +++++++++++- src/tds/codec/column_data/bit.rs | 40 ++ src/tds/codec/column_data/datetime2.rs | 33 +- src/tds/codec/column_data/datetimeoffsetn.rs | 27 +- src/tds/codec/column_data/fixed_len.rs | 27 +- src/tds/codec/column_data/image.rs | 34 +- src/tds/codec/column_data/money.rs | 53 ++ src/tds/codec/column_data/plp.rs | 92 ++- src/tds/codec/column_data/string.rs | 12 +- src/tds/codec/column_data/text.rs | 53 +- src/tds/codec/column_data/var_len.rs | 131 +++- src/tds/codec/decode.rs | 122 ++++ src/tds/codec/encode.rs | 23 + src/tds/codec/guid.rs | 29 + src/tds/codec/iterator_ext.rs | 17 + src/tds/codec/login.rs | 141 +++- src/tds/codec/packet.rs | 62 ++ src/tds/codec/pre_login.rs | 143 +++- src/tds/codec/token.rs | 18 + src/tds/codec/token/token_alt_meta_data.rs | 8 +- src/tds/codec/token/token_alt_row.rs | 23 + src/tds/codec/token/token_col_info.rs | 75 ++ src/tds/codec/token/token_col_metadata.rs | 681 ++++++++++++++++++- src/tds/codec/token/token_done.rs | 146 +++- src/tds/codec/token/token_env_change.rs | 333 ++++++++- src/tds/codec/token/token_error.rs | 115 +++- src/tds/codec/token/token_feature_ext_ack.rs | 83 +++ src/tds/codec/token/token_fed_auth_info.rs | 130 ++++ src/tds/codec/token/token_info.rs | 83 ++- src/tds/codec/token/token_login_ack.rs | 48 ++ src/tds/codec/token/token_order.rs | 40 +- src/tds/codec/token/token_return_value.rs | 62 ++ src/tds/codec/token/token_row.rs | 185 ++++- src/tds/codec/token/token_row/into_row.rs | 55 ++ src/tds/codec/token/token_session_state.rs | 127 ++++ src/tds/codec/token/token_tab_name.rs | 33 + src/tds/codec/type_info.rs | 237 ++++++- src/tds/codec/type_info_tvp.rs | 72 ++ src/tds/collation.rs | 429 +++++++++++- src/tds/context.rs | 105 +++ src/tds/numeric.rs | 269 +++++++- src/tds/stream/command.rs | 9 +- src/tds/stream/query.rs | 9 +- src/tds/time.rs | 188 +++++ src/tds/time/chrono.rs | 196 +++++- src/tds/time/time.rs | 218 +++++- src/tds/xml.rs | 61 ++ src/to_sql.rs | 205 ++++++ 58 files changed, 6439 insertions(+), 157 deletions(-) diff --git a/src/client/auth.rs b/src/client/auth.rs index c440d204b..686c2d764 100644 --- a/src/client/auth.rs +++ b/src/client/auth.rs @@ -30,7 +30,7 @@ impl Debug for SqlServerAuth { )] pub struct WindowsAuth { pub(crate) user: String, - pub(crate) password: String, + pub(crate) password: Zeroizing, pub(crate) domain: Option, } @@ -50,7 +50,7 @@ impl Debug for WindowsAuth { } /// Defines the method of authentication to the server. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum AuthMethod { /// Authenticate directly with SQL Server. SqlServer(SqlServerAuth), @@ -82,6 +82,26 @@ pub enum AuthMethod { None, } +// Manual Debug so the AAD bearer token is never printed. The credential-bearing +// SqlServer/Windows variants delegate to their inner types, which already redact. +impl Debug for AuthMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SqlServer(a) => f.debug_tuple("SqlServer").field(a).finish(), + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] + Self::Windows(a) => f.debug_tuple("Windows").field(a).finish(), + #[cfg(any( + all(windows, feature = "winauth"), + all(unix, feature = "integrated-auth-gssapi"), + doc + ))] + Self::Integrated => f.write_str("Integrated"), + Self::AADToken(_) => f.debug_tuple("AADToken").field(&"").finish(), + Self::None => f.write_str("None"), + } + } +} + impl AuthMethod { /// Construct a new SQL Server authentication configuration. pub fn sql_server(user: impl ToString, password: impl ToString) -> Self { @@ -105,7 +125,7 @@ impl AuthMethod { Self::Windows(WindowsAuth { user: user.to_string(), - password: password.to_string(), + password: Zeroizing::new(password.to_string()), domain: domain.map(|s| s.to_string()), }) } @@ -136,4 +156,46 @@ mod tests { assert!(password.is_empty()); } + + #[test] + fn debug_redacts_credentials() { + let sql = format!("{:?}", AuthMethod::sql_server("sa", "sql-secret")); + assert!(!sql.contains("sql-secret"), "SQL password leaked: {sql}"); + + let aad = format!("{:?}", AuthMethod::aad_token("aad-secret-token")); + assert!(!aad.contains("aad-secret-token"), "AAD token leaked: {aad}"); + assert!(aad.contains("HIDDEN")); + } + + #[test] + fn debug_none_variant() { + assert_eq!(format!("{:?}", AuthMethod::None), "None"); + } + + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))] + #[test] + fn windows_auth_parses_domain_and_debug_redacts() { + // `DOMAIN\user` form exercises the domain-splitting branch of `windows()`. + let auth = AuthMethod::windows("DOMAIN\\user", "win-secret"); + let dbg = format!("{:?}", auth); + assert!(dbg.contains("Windows"), "variant name missing: {dbg}"); + assert!(dbg.contains("DOMAIN"), "domain not preserved: {dbg}"); + assert!(dbg.contains("user"), "user not preserved: {dbg}"); + assert!(!dbg.contains("win-secret"), "password leaked: {dbg}"); + + // No backslash exercises the domain-less branch. + let plain = AuthMethod::windows("plainuser", "pw"); + let dbg = format!("{:?}", plain); + assert!(dbg.contains("plainuser"), "user not preserved: {dbg}"); + assert!(dbg.contains("None"), "domain should be None: {dbg}"); + } + + #[cfg(any( + all(windows, feature = "winauth"), + all(unix, feature = "integrated-auth-gssapi") + ))] + #[test] + fn integrated_debug() { + assert_eq!(format!("{:?}", AuthMethod::Integrated), "Integrated"); + } } diff --git a/src/client/config.rs b/src/client/config.rs index c7fada926..f2bb32433 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -866,9 +866,17 @@ pub(crate) trait ConfigString { Err(_) if val.eq_ignore_ascii_case("strict") && cfg!(feature = "tds80") => { Ok(EncryptionLevel::Strict) } + Err(_) if val.eq_ignore_ascii_case("strict") => Err(crate::Error::Conversion( + "encrypt=strict requires the crate's `tds80` feature to be enabled".into(), + )), Err(e) => Err(e), }) - .unwrap_or(Ok(EncryptionLevel::Off)) + // When the `encrypt` keyword is omitted, default to requiring + // encryption — matching `Config::default()` and modern ADO.NET + // (`Encrypt=Mandatory`). Callers who want an unencrypted connection + // must opt out explicitly with `encrypt=false` (or + // `encrypt=DANGER_PLAINTEXT`). + .unwrap_or(Ok(EncryptionLevel::Required)) } #[cfg(not(any( @@ -940,6 +948,46 @@ mod tests { assert_eq!(Some("master"), config.database.as_deref()); } + #[test] + fn config_from_builder_carries_builder_settings() { + // `From` must return the built inner config, not a default. + let config: Config = Config::builder().host("db.internal").port(2020).into(); + assert_eq!("db.internal", config.get_host()); + assert_eq!(2020, config.get_port()); + } + + #[test] + fn get_packet_size_reflects_the_set_value() { + let mut config = Config::new(); + assert_eq!(config.get_packet_size(), None); + config.packet_size(8192); + assert_eq!(config.get_packet_size(), Some(8192)); + } + + #[test] + fn from_jdbc_string_parses_host_and_port() { + let config = + Config::from_jdbc_string("jdbc:sqlserver://db.example.com:2345").expect("valid jdbc"); + assert_eq!("db.example.com", config.get_host()); + assert_eq!(2345, config.get_port()); + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[test] + fn get_hostname_in_certificate_falls_back_to_host() { + let mut config = Config::new(); + config.host("real.host"); + // Unset: falls back to the connection host. + assert_eq!(config.get_hostname_in_certificate(), "real.host"); + // Set: returns the explicit certificate hostname. + config.hostname_in_certificate("cert.host"); + assert_eq!(config.get_hostname_in_certificate(), "cert.host"); + } + #[cfg(any( feature = "rustls", feature = "native-tls", @@ -1037,4 +1085,160 @@ mod tests { other => panic!("expected Windows NTLM auth, got {other:?}"), } } + + #[test] + fn config_direct_setters_populate_fields() { + let mut config = Config::new(); + config.database("northwind"); + config.instance_name("SQLEXPRESS"); + config.client_name("workstation-7"); + + assert_eq!(Some("northwind"), config.database.as_deref()); + assert_eq!(Some("SQLEXPRESS"), config.instance_name.as_deref()); + assert_eq!(Some("workstation-7"), config.client_name.as_deref()); + } + + #[test] + fn get_port_defaults_without_port_or_instance() { + // No explicit port and no instance -> default SQL Server port. + let config = Config::new(); + assert_eq!(1433, config.get_port()); + } + + #[test] + fn get_port_uses_sql_browser_port_for_named_instance() { + // A named instance without an explicit port -> SQL Browser port. + let mut config = Config::new(); + config.instance_name("SQLEXPRESS"); + assert_eq!(1434, config.get_port()); + } + + #[test] + #[should_panic(expected = "mutual exclusive")] + fn trust_cert_after_trust_cert_ca_panics() { + let mut config = Config::new(); + config.trust_cert_ca("/tmp/ca.crt"); + config.trust_cert(); + } + + #[test] + #[should_panic(expected = "mutual exclusive")] + fn trust_cert_ca_after_trust_cert_panics() { + let mut config = Config::new(); + config.trust_cert(); + config.trust_cert_ca("/tmp/ca.crt"); + } + + #[test] + fn trust_cert_ca_sets_ca_location() { + let mut config = Config::new(); + config.trust_cert_ca("/tmp/ca.crt"); + assert!(matches!( + config.trust, + TrustConfig::CaCertificateLocation(_) + )); + } + + #[test] + fn config_builder_covers_all_setters() { + let config = Config::builder() + .host("localhost") + .instance_name("SQLEXPRESS") + .encryption(EncryptionLevel::Off) + .trust_cert_ca("/tmp/ca.crt") + .build(); + + assert_eq!(Some("SQLEXPRESS"), config.instance_name.as_deref()); + assert!(matches!(config.encryption, EncryptionLevel::Off)); + assert!(matches!( + config.trust, + TrustConfig::CaCertificateLocation(_) + )); + } + + #[test] + fn config_builder_trust_cert_sets_trust_all() { + let config = Config::builder().trust_cert().build(); + assert!(matches!(config.trust, TrustConfig::TrustAll)); + } + + #[test] + #[should_panic(expected = "mutual exclusive")] + fn config_builder_trust_cert_after_ca_panics() { + Config::builder().trust_cert_ca("/tmp/ca.crt").trust_cert(); + } + + #[test] + #[should_panic(expected = "mutual exclusive")] + fn config_builder_trust_cert_ca_after_trust_cert_panics() { + Config::builder().trust_cert().trust_cert_ca("/tmp/ca.crt"); + } + + #[test] + fn from_ado_string_populates_optional_fields() { + let config = Config::from_ado_string( + "server=tcp:my-server.com\\SQLEXPRESS;database=northwind;\ + HostNameInCertificate=cert.host;WorkstationID=ws-1", + ) + .expect("valid ado string"); + + assert_eq!("my-server.com", config.get_host()); + assert_eq!(Some("SQLEXPRESS"), config.instance_name.as_deref()); + assert_eq!(Some("northwind"), config.database.as_deref()); + assert_eq!(Some("cert.host"), config.hostname_in_certificate.as_deref()); + assert_eq!(Some("ws-1"), config.client_name.as_deref()); + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[test] + fn client_cert_source_debug_formats_cert_and_key() { + let mut config = Config::new(); + config.client_certificate("/tmp/client.pem", "/tmp/client.key"); + + let dbg = format!("{:?}", config.get_client_certificate().unwrap().source); + assert!(dbg.contains("CertAndKey")); + assert!(dbg.contains("client.pem")); + assert!(dbg.contains("client.key")); + } + + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + #[test] + fn config_builder_sets_pkcs12_client_certificate() { + let config = Config::builder() + .client_certificate_pkcs12("/tmp/identity.pfx", "s3cr3t") + .build(); + + match &config + .get_client_certificate() + .expect("client certificate should be set") + .source + { + ClientCertSource::Pkcs12 { path, password } => { + assert_eq!(path, &PathBuf::from("/tmp/identity.pfx")); + assert_eq!(password.as_str(), "s3cr3t"); + } + other => panic!("expected Pkcs12 source, got {other:?}"), + } + } + + #[cfg(all(unix, feature = "sspi-rs"))] + #[test] + fn ado_integrated_security_sspi_with_partial_credentials_uses_windows() { + // Only a username (no password) -> falls into the catch-all NTLM arm. + let config = Config::from_ado_string( + "server=tcp:localhost,1433;IntegratedSecurity=SSPI;uid=onlyuser", + ) + .unwrap(); + + match config.auth { + AuthMethod::Windows(auth) => { + assert_eq!("onlyuser", auth.user); + } + other => panic!("expected Windows auth, got {other:?}"), + } + } } diff --git a/src/client/config/ado_net.rs b/src/client/config/ado_net.rs index ac4fb9738..3067b75f7 100644 --- a/src/client/config/ado_net.rs +++ b/src/client/config/ado_net.rs @@ -230,6 +230,39 @@ mod tests { Ok(()) } + #[test] + fn server_parsing_too_many_parts_is_error() -> crate::Result<()> { + // The Server value must have at most two comma-separated parts + // (host[,port]). Three parts is invalid and must error. The guard is + // `parts.is_empty() || parts.len() >= 3`; a `&&` mutation would never + // trigger (a slice cannot be both empty and have >= 3 parts), so this + // three-part value would be wrongly accepted. + let ado: AdoNetConfig = "server=tcp:my-server.com,1433,extra".parse()?; + assert!(ado.server().is_err()); + + let ado: AdoNetConfig = "server=my-server.com,1433,extra".parse()?; + assert!(ado.server().is_err()); + + Ok(()) + } + + #[test] + fn server_parsing_missing_key() -> crate::Result<()> { + // No `server`/`data source` key at all -> an all-`None` definition. + let ado: AdoNetConfig = "database=Foo".parse()?; + let server = ado.server()?; + + assert_eq!(None, server.host); + assert_eq!(None, server.port); + assert_eq!(None, server.instance); + + // And the same path through the public constructor. + let config = crate::Config::from_ado_string("database=Foo")?; + assert_eq!("localhost", config.get_host()); + + Ok(()) + } + #[test] fn database_parsing() -> crate::Result<()> { let test_str = "database=Foo"; @@ -465,7 +498,7 @@ mod tests { let test_str = ""; let ado: AdoNetConfig = test_str.parse()?; - assert_eq!(EncryptionLevel::Off, ado.encrypt()?); + assert_eq!(EncryptionLevel::Required, ado.encrypt()?); Ok(()) } diff --git a/src/client/config/jdbc.rs b/src/client/config/jdbc.rs index 4168cf975..f8f9db985 100644 --- a/src/client/config/jdbc.rs +++ b/src/client/config/jdbc.rs @@ -314,7 +314,7 @@ mod tests { let test_str = "jdbc:sqlserver://my-server.com:4200;"; let jdbc: JdbcConfig = test_str.parse()?; - assert_eq!(EncryptionLevel::Off, jdbc.encrypt()?); + assert_eq!(EncryptionLevel::Required, jdbc.encrypt()?); Ok(()) } diff --git a/src/client/connection.rs b/src/client/connection.rs index 1729aa0b1..0fd664bde 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -63,6 +63,13 @@ where flushed: bool, context: Context, buf: BytesMut, + /// Set for the duration of a multi-packet write. A message is only partly + /// on the wire while this is `true`; if the writing future is dropped + /// (a cancelled `query`/`execute`, a `select!` losing the race, a + /// `tokio::time::timeout` firing) the flag stays set, so the next write on + /// the same connection fails cleanly instead of appending a second message + /// after a half-sent one and silently desyncing the server. + poisoned: bool, } impl Debug for Connection { @@ -123,6 +130,7 @@ impl Connection { context, flushed: false, buf: BytesMut::new(), + poisoned: false, }; let fed_auth_required = matches!(config.auth, AuthMethod::AADToken(_)); @@ -209,12 +217,17 @@ impl Connection { where E: Sized + Encode, { + self.ensure_not_poisoned()?; self.flushed = false; let packet_size = (self.context.packet_size() as usize) - HEADER_BYTES; let mut payload = BytesMut::new(); item.encode(&mut payload)?; + // Mark the connection poisoned across the multi-packet write; a clean + // completion clears it below. A future dropped mid-loop leaves it set. + self.poisoned = true; + while !payload.is_empty() { let writable = cmp::min(payload.len(), packet_size); let split_payload = payload.split_to(writable); @@ -235,7 +248,22 @@ impl Connection { } self.flush_sink().await?; + self.poisoned = false; + + Ok(()) + } + /// Returns an error if a previous multi-packet write on this connection was + /// interrupted (e.g. the query/execute future was cancelled), which would + /// have left a partial message on the wire. The connection cannot be safely + /// reused in that state and should be dropped. + fn ensure_not_poisoned(&self) -> crate::Result<()> { + if self.poisoned { + return Err(crate::Error::Protocol( + "connection was left in an inconsistent state by a cancelled write and can no longer be used; open a new connection" + .into(), + )); + } Ok(()) } @@ -244,10 +272,13 @@ impl Connection { mut header: PacketHeader, mut payload: Zeroizing>, ) -> crate::Result<()> { + self.ensure_not_poisoned()?; self.flushed = false; let packet_size = (self.context.packet_size() as usize) - HEADER_BYTES; let mut offset = 0; + self.poisoned = true; + while offset < payload.len() { let end = cmp::min(payload.len(), offset + packet_size); @@ -274,6 +305,7 @@ impl Connection { } self.transport.flush().await?; + self.poisoned = false; Ok(()) } @@ -328,22 +360,42 @@ impl Connection { /// Calling this will slow down the queries if stream is still dirty if all /// results are not handled. pub async fn flush_stream(&mut self) -> crate::Result<()> { + // If a previous write was cancelled mid-message the connection is + // already known-bad; fail fast rather than layering a new request on + // top of it. + self.ensure_not_poisoned()?; + + // Discard any partially-consumed packet payload, then drain whole + // packets up to the end-of-message marker. Truncating `buf` and + // re-reading on packet boundaries resynchronises the token stream even + // if a previous result stream was dropped part-way through a value + // (the lost bytes belonged to a packet we are discarding anyway). self.buf.truncate(0); if self.flushed { return Ok(()); } - while let Some(packet) = self.try_next().await? { - event!( - Level::WARN, - "Flushing unhandled packet from the wire. Please consume your streams!", - ); + loop { + match self.try_next().await { + Ok(Some(packet)) => { + event!( + Level::WARN, + "Flushing unhandled packet from the wire. Please consume your streams!", + ); - let is_last = packet.is_last(); - - if is_last { - break; + if packet.is_last() { + break; + } + } + Ok(None) => break, + // The stream could not be drained cleanly (e.g. it was + // abandoned at an unrecoverable offset). Poison the connection + // so it is not silently reused in an inconsistent state. + Err(e) => { + self.poisoned = true; + return Err(e); + } } } @@ -451,7 +503,11 @@ impl Connection { let token = TokenSspi::new(sspi_response); self.send(header, token).await?; } - None => unreachable!(), + None => { + return Err(crate::Error::Protocol( + "NTLM handshake produced no response to the server challenge".into(), + )) + } } } #[cfg(all(unix, feature = "integrated-auth-gssapi"))] @@ -504,7 +560,7 @@ impl Connection { let identity = AuthIdentity { username, - password: auth.password.clone().into(), + password: auth.password.to_string().into(), }; let mut creds = ntlm @@ -577,7 +633,7 @@ impl Connection { AuthMethod::Windows(auth) => { let spn = self.context.spn().to_string(); let builder = winauth::NtlmV2ClientBuilder::new().target_spn(spn); - let mut client = builder.build(auth.domain, auth.user, auth.password); + let mut client = builder.build(auth.domain, auth.user, auth.password.to_string()); login_message.integrated_security(client.next_bytes(None)?); @@ -598,7 +654,11 @@ impl Connection { let token = TokenSspi::new(sspi_response); self.send(header, token).await?; } - None => unreachable!(), + None => { + return Err(crate::Error::Protocol( + "NTLM handshake produced no response to the server challenge".into(), + )) + } } } AuthMethod::None => { @@ -621,8 +681,12 @@ impl Connection { } AuthMethod::AADToken(token) => { login_message.aad_token(token, prelogin.fed_auth_required, prelogin.nonce); + // Encode into a zeroizing buffer and use the sensitive-login + // path so the bearer token does not linger in freed heap memory. + let payload = login_message.encode_to_vec()?; let id = self.context.next_packet_id(); - self.send(PacketHeader::login(id), login_message).await?; + self.send_sensitive_login(PacketHeader::login(id), payload) + .await?; self = self.post_login_encryption(encryption); } } @@ -683,6 +747,7 @@ impl Connection { context, flushed: false, buf: BytesMut::new(), + poisoned: false, }) } } diff --git a/src/client/tls.rs b/src/client/tls.rs index cad84ba35..bab81076b 100644 --- a/src/client/tls.rs +++ b/src/client/tls.rs @@ -217,11 +217,25 @@ impl AsyncRead for TlsPreloginWrapper< let header = PacketHeader::decode(&mut BytesMut::from(&inner.header_buf[..])) .map_err(io::Error::other)?; - // We only get pre-login packets in the handshake process. - assert_eq!(header.r#type(), PacketType::PreLogin); + // We only get pre-login packets in the handshake process. This runs + // before any certificate has been validated, so the bytes are fully + // untrusted: reject anything unexpected instead of panicking. + if header.r#type() != PacketType::PreLogin { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected a pre-login packet during the TLS handshake", + ))); + } - // And we know from this point on how much data we should expect - inner.read_remaining = header.length() as usize - HEADER_BYTES; + // And we know from this point on how much data we should expect. + inner.read_remaining = (header.length() as usize) + .checked_sub(HEADER_BYTES) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "pre-login packet length shorter than its header", + ) + })?; event!( Level::TRACE, diff --git a/src/client/tls_stream/rustls_tls_stream.rs b/src/client/tls_stream/rustls_tls_stream.rs index 0690cca13..d723c7388 100644 --- a/src/client/tls_stream/rustls_tls_stream.rs +++ b/src/client/tls_stream/rustls_tls_stream.rs @@ -174,7 +174,7 @@ impl TlsStream { } TrustConfig::Default => { event!(Level::DEBUG, "Using default trust configuration."); - builder.with_native_roots() + builder.with_native_roots()? } }; @@ -339,16 +339,32 @@ fn load_client_auth( } trait ConfigBuilderExt { - fn with_native_roots(self) -> ConfigBuilder; + fn with_native_roots(self) -> crate::Result>; } impl ConfigBuilderExt for ConfigBuilder { - fn with_native_roots(self) -> ConfigBuilder { + fn with_native_roots(self) -> crate::Result> { let mut roots = RootCertStore::empty(); let mut valid_count = 0; let mut invalid_count = 0; + // Loading the OS trust store can fail (stripped container, unreadable + // store) and can legitimately come back empty. Neither is a reason to + // abort the whole process: surface a catchable error instead of the + // previous `.expect()` / `assert!` panics. `load_native_certs` returns + // a `CertificateResult` carrying both the parsed certs and any errors. let native_certs = rustls_native_certs::load_native_certs(); + + if native_certs.certs.is_empty() && !native_certs.errors.is_empty() { + return Err(crate::Error::Io { + kind: IoErrorKind::NotFound, + message: format!( + "could not load platform certificates: {:?}", + native_certs.errors + ), + }); + } + for err in native_certs.errors { event!( Level::DEBUG, @@ -370,8 +386,14 @@ impl ConfigBuilderExt for ConfigBuilder { valid_count, invalid_count ); - assert!(!roots.is_empty(), "no CA certificates found"); - self.with_root_certificates(roots) + if roots.is_empty() { + return Err(crate::Error::Io { + kind: IoErrorKind::NotFound, + message: "no usable CA certificates found in the platform trust store".to_string(), + }); + } + + Ok(self.with_root_certificates(roots)) } } diff --git a/src/from_sql.rs b/src/from_sql.rs index f8bedff16..3fdcc20c1 100644 --- a/src/from_sql.rs +++ b/src/from_sql.rs @@ -150,4 +150,143 @@ mod tests { assert_eq!(None, i32::from_sql(&data).unwrap()); assert_eq!(None, i32::from_sql_owned(ColumnData::I16(None)).unwrap()); } + + #[test] + fn bool_from_bit() { + let data = ColumnData::Bit(Some(true)); + assert_eq!(Some(true), bool::from_sql(&data).unwrap()); + assert_eq!(Some(true), bool::from_sql_owned(data).unwrap()); + } + + #[test] + fn u8_from_u8_and_null_i32() { + let data = ColumnData::U8(Some(5)); + assert_eq!(Some(5u8), u8::from_sql(&data).unwrap()); + assert_eq!(Some(5u8), u8::from_sql_owned(data).unwrap()); + + let null = ColumnData::I32(None); + assert_eq!(None, u8::from_sql(&null).unwrap()); + assert_eq!(None, u8::from_sql_owned(null).unwrap()); + } + + #[test] + fn i16_from_wrong_variant_errors() { + let data = ColumnData::F64(Some(1.0)); + let err = i16::from_sql(&data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + } + + #[test] + fn i64_from_i64_and_null() { + let data = ColumnData::I64(Some(42)); + assert_eq!(Some(42i64), i64::from_sql(&data).unwrap()); + assert_eq!(Some(42i64), i64::from_sql_owned(data).unwrap()); + + let null = ColumnData::U8(None); + assert_eq!(None, i64::from_sql_owned(null).unwrap()); + } + + #[test] + fn f32_and_f64_from_sql() { + let f32_data = ColumnData::F32(Some(1.5)); + assert_eq!(Some(1.5f32), f32::from_sql(&f32_data).unwrap()); + + let f64_data = ColumnData::F64(Some(2.5)); + assert_eq!(Some(2.5f64), f64::from_sql(&f64_data).unwrap()); + } + + #[test] + fn uuid_from_guid() { + let uuid = Uuid::new_v4(); + let data = ColumnData::Guid(Some(uuid)); + assert_eq!(Some(uuid), Uuid::from_sql(&data).unwrap()); + assert_eq!(Some(uuid), Uuid::from_sql_owned(data).unwrap()); + } + + #[test] + fn numeric_from_numeric() { + let numeric = crate::tds::Numeric::new_with_scale(1234, 2); + let data = ColumnData::Numeric(Some(numeric)); + assert_eq!(Some(numeric), Numeric::from_sql(&data).unwrap()); + assert_eq!(Some(numeric), Numeric::from_sql_owned(data).unwrap()); + } + + #[test] + fn xml_data_owned_and_borrowed() { + let xml = XmlData::new("".to_string()); + let data = ColumnData::Xml(Some(std::borrow::Cow::Owned(xml.clone()))); + + let borrowed = <&XmlData as FromSql>::from_sql(&data).unwrap().unwrap(); + assert_eq!(borrowed.to_string(), xml.to_string()); + + let owned = XmlData::from_sql_owned(data).unwrap().unwrap(); + assert_eq!(owned.to_string(), xml.to_string()); + } + + #[test] + fn xml_data_wrong_variant_errors() { + let data = ColumnData::I32(Some(1)); + let err = XmlData::from_sql_owned(data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + + let data = ColumnData::I32(Some(1)); + let err = <&XmlData as FromSql>::from_sql(&data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + } + + #[test] + fn string_owned_and_borrowed_str() { + let data = ColumnData::String(Some(std::borrow::Cow::Borrowed("hello"))); + let borrowed = <&str as FromSql>::from_sql(&data).unwrap(); + assert_eq!(Some("hello"), borrowed); + + let owned = String::from_sql_owned(data).unwrap(); + assert_eq!(Some("hello".to_string()), owned); + } + + #[test] + fn string_wrong_variant_errors() { + let data = ColumnData::I32(Some(1)); + let err = String::from_sql_owned(data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + + let data = ColumnData::I32(Some(1)); + let err = <&str as FromSql>::from_sql(&data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + } + + #[test] + fn binary_owned_and_borrowed_slice() { + let bytes = vec![1u8, 2, 3]; + let data = ColumnData::Binary(Some(std::borrow::Cow::Owned(bytes.clone()))); + + let borrowed = <&[u8] as FromSql>::from_sql(&data).unwrap(); + assert_eq!(Some(bytes.as_slice()), borrowed); + + let owned = Vec::::from_sql_owned(data).unwrap(); + assert_eq!(Some(bytes), owned); + } + + #[test] + fn binary_wrong_variant_errors() { + let data = ColumnData::I32(Some(1)); + let err = Vec::::from_sql_owned(data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + + let data = ColumnData::I32(Some(1)); + let err = <&[u8] as FromSql>::from_sql(&data).unwrap_err(); + assert!(format!("{}", err).contains("cannot interpret")); + } + + #[test] + fn null_string_and_binary_values() { + let data = ColumnData::String(None); + assert_eq!(None, String::from_sql_owned(data).unwrap()); + + let data = ColumnData::Binary(None); + assert_eq!(None, Vec::::from_sql_owned(data).unwrap()); + + let data = ColumnData::Xml(None); + assert_eq!(None, XmlData::from_sql_owned(data).unwrap()); + } } diff --git a/src/sql_browser.rs b/src/sql_browser.rs index 5166674d5..15591f477 100644 --- a/src/sql_browser.rs +++ b/src/sql_browser.rs @@ -34,12 +34,23 @@ fn get_port_from_sql_browser_reply( buf.truncate(len); - let err = crate::Error::Conversion( - format!("Could not resolve SQL browser instance {}", instance_name).into(), - ); + // Built fresh on each failure path so the descriptive context (which + // instance failed to resolve) is preserved rather than being collapsed into + // a bare `Error::Utf8`/`Error::ParseInt` by `?`. + let err = || { + crate::Error::Conversion( + format!("Could not resolve SQL browser instance {}", instance_name).into(), + ) + }; - if len == 0 { - return Err(err); + // The SSRP reply is [SVR_RESP(1 byte)][RESP_SIZE(2 bytes, LE)][data...], so + // the instance data starts at offset 3. A reply shorter than that 3-byte + // header is malformed — and SSRP is unauthenticated UDP, so a spoofed or + // truncated datagram is fully attacker-controlled. Guard it explicitly: + // `&buf[3..len]` would otherwise panic ("slice index starts at 3 but ends + // at 1") for a 1- or 2-byte reply. + if len < 3 { + return Err(err()); } let rsp = &buf[3..len]; @@ -49,8 +60,9 @@ fn get_port_from_sql_browser_reply( .rev() .position(|window| window == DELIMITER) .and_then(|pos| rsp[(rsp.len() - pos)..].split(|item| *item == b';').next()) - .ok_or(err) - .and_then(|val| Ok(std::str::from_utf8(val)?.parse()?))?; + .and_then(|val| std::str::from_utf8(val).ok()) + .and_then(|val| val.parse().ok()) + .ok_or_else(err)?; Ok(port) } diff --git a/src/sql_read_bytes.rs b/src/sql_read_bytes.rs index 0455a1ce7..75fb20acd 100644 --- a/src/sql_read_bytes.rs +++ b/src/sql_read_bytes.rs @@ -332,6 +332,247 @@ bytes_reader!(ReadF64, f64, get_f64); bytes_reader!(ReadF32Le, f32, get_f32_le); bytes_reader!(ReadF64Le, f64, get_f64_le); +#[cfg(test)] +mod tests { + use super::test_utils::IntoSqlReadBytes; + use crate::SqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn read_i8_value() { + let mut buf = BytesMut::new(); + buf.put_i8(-5); + assert_eq!(buf.into_sql_read_bytes().read_i8().await.unwrap(), -5); + } + + #[tokio::test] + async fn read_u32_big_endian() { + let mut buf = BytesMut::new(); + buf.put_u32(0x01020304); + assert_eq!( + buf.into_sql_read_bytes().read_u32().await.unwrap(), + 0x01020304 + ); + } + + #[tokio::test] + async fn read_f32_and_f64_big_endian() { + let mut buf = BytesMut::new(); + buf.put_f32(1.5); + assert_eq!(buf.into_sql_read_bytes().read_f32().await.unwrap(), 1.5); + + let mut buf = BytesMut::new(); + buf.put_f64(2.5); + assert_eq!(buf.into_sql_read_bytes().read_f64().await.unwrap(), 2.5); + } + + #[tokio::test] + async fn read_f32_and_f64_little_endian() { + let mut buf = BytesMut::new(); + buf.put_f32_le(1.5); + assert_eq!(buf.into_sql_read_bytes().read_f32_le().await.unwrap(), 1.5); + + let mut buf = BytesMut::new(); + buf.put_f64_le(2.5); + assert_eq!(buf.into_sql_read_bytes().read_f64_le().await.unwrap(), 2.5); + } + + #[tokio::test] + async fn read_u128_and_i128_le() { + let mut buf = BytesMut::new(); + buf.put_u128_le(12345); + assert_eq!( + buf.into_sql_read_bytes().read_u128_le().await.unwrap(), + 12345 + ); + + let mut buf = BytesMut::new(); + buf.put_i128_le(-12345); + assert_eq!( + buf.into_sql_read_bytes().read_i128_le().await.unwrap(), + -12345 + ); + } + + #[tokio::test] + async fn read_b_varchar_and_us_varchar() { + let mut buf = BytesMut::new(); + buf.put_u8(2); + buf.put_u16_le('h' as u16); + buf.put_u16_le('i' as u16); + assert_eq!( + buf.into_sql_read_bytes().read_b_varchar().await.unwrap(), + "hi" + ); + + let mut buf = BytesMut::new(); + buf.put_u16_le(2); + buf.put_u16_le('h' as u16); + buf.put_u16_le('i' as u16); + assert_eq!( + buf.into_sql_read_bytes().read_us_varchar().await.unwrap(), + "hi" + ); + } + + #[tokio::test] + async fn context_and_context_mut_accessible() { + let buf = BytesMut::new(); + let mut reader = buf.into_sql_read_bytes(); + assert_eq!(reader.context().packet_size(), 4096); + reader.context_mut().set_packet_size(8192); + assert_eq!(reader.context().packet_size(), 8192); + } + + // The length prefix cannot be read (empty wire) — exercises the error arm of + // the varchar length read (`Poll::Ready(Err(..))`). + #[tokio::test] + async fn b_varchar_length_read_error() { + let buf = BytesMut::new(); + assert!(buf.into_sql_read_bytes().read_b_varchar().await.is_err()); + } + + // The length is read but the character payload is truncated — exercises the + // error arm of the inner u16 read within the varchar loop. + #[tokio::test] + async fn b_varchar_data_read_error() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // announce one u16 char... + buf.put_u8(0x41); // ...but supply only a single byte + assert!(buf.into_sql_read_bytes().read_b_varchar().await.is_err()); + } + + // A lone UTF-16 surrogate makes `String::from_utf16` fail — exercises the + // invalid-UTF-16 error mapping at the end of the varchar reader. + #[tokio::test] + async fn b_varchar_invalid_utf16_error() { + let mut buf = BytesMut::new(); + buf.put_u8(1); + buf.put_u16_le(0xD800); // unpaired high surrogate + assert!(buf.into_sql_read_bytes().read_b_varchar().await.is_err()); + } + + // `debug_buffer` for the test reader is a `todo!()`; calling it must panic. + #[test] + #[should_panic] + fn debug_buffer_panics() { + let reader = BytesMut::new().into_sql_read_bytes(); + reader.debug_buffer(); + } +} + +// Tests for the `Poll::Pending` / clean-EOF branches of the readers, which +// require an `AsyncRead` that can return `Pending` / `Ok(0)` on demand and a +// manually driven poll. +#[cfg(test)] +mod poll_branch_tests { + use crate::tds::Context; + use crate::SqlReadBytes; + use bytes::{BufMut, BytesMut}; + use futures_util::io::AsyncRead; + use std::future::Future; + use std::io; + use std::pin::Pin; + use std::task::{Context as TaskContext, Poll}; + + enum Then { + Pending, + Eof, + } + + // Hands out `data` while enough bytes remain, then switches to returning + // either `Poll::Pending` or a clean EOF (`Ok(0)`). + struct ScriptedReader { + data: BytesMut, + then: Then, + ctx: Context, + } + + impl ScriptedReader { + fn new(data: BytesMut, then: Then) -> Self { + Self { + data, + then, + ctx: Context::new(), + } + } + } + + impl AsyncRead for ScriptedReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + buf: &mut [u8], + ) -> Poll> { + let this = self.get_mut(); + let size = buf.len(); + + if size > 0 && this.data.len() >= size { + buf.copy_from_slice(this.data.split_to(size).as_ref()); + return Poll::Ready(Ok(size)); + } + + match this.then { + Then::Pending => Poll::Pending, + Then::Eof => Poll::Ready(Ok(0)), + } + } + } + + impl SqlReadBytes for ScriptedReader { + fn debug_buffer(&self) {} + fn context(&self) -> &Context { + &self.ctx + } + fn context_mut(&mut self) -> &mut Context { + &mut self.ctx + } + } + + fn poll_once(fut: F) -> Poll { + let waker = std::task::Waker::noop(); + let mut cx = TaskContext::from_waker(waker); + let mut fut = fut; + // Safety: `fut` lives on the stack for the duration of this call and is + // never moved after being pinned. + let fut = unsafe { Pin::new_unchecked(&mut fut) }; + fut.poll(&mut cx) + } + + // The varchar length read yields `Pending` (no bytes available yet). + #[test] + fn varchar_length_pending() { + let mut reader = ScriptedReader::new(BytesMut::new(), Then::Pending); + assert!(matches!(poll_once(reader.read_b_varchar()), Poll::Pending)); + } + + // The length is read, but the character payload read yields `Pending`. + #[test] + fn varchar_data_pending() { + let mut data = BytesMut::new(); + data.put_u8(1); // length available, character bytes are not + let mut reader = ScriptedReader::new(data, Then::Pending); + assert!(matches!(poll_once(reader.read_b_varchar()), Poll::Pending)); + } + + // A fixed-width numeric read yields `Pending` when no bytes are available. + #[test] + fn fixed_width_read_pending() { + let mut reader = ScriptedReader::new(BytesMut::new(), Then::Pending); + assert!(matches!(poll_once(reader.read_u32_le()), Poll::Pending)); + } + + // A clean EOF (`Ok(0)`) mid-read surfaces as an `UnexpectedEof` error. + #[test] + fn fixed_width_read_unexpected_eof() { + let mut reader = ScriptedReader::new(BytesMut::new(), Then::Eof); + match poll_once(reader.read_u8()) { + Poll::Ready(Err(e)) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof), + other => panic!("expected UnexpectedEof, got {other:?}"), + } + } +} + #[cfg(test)] pub(crate) mod test_utils { use crate::tds::Context; @@ -352,12 +593,16 @@ pub(crate) mod test_utils { type T = BytesMutReader; fn into_sql_read_bytes(self) -> Self::T { - BytesMutReader { buf: self } + BytesMutReader { + buf: self, + ctx: Context::new(), + } } } pub(crate) struct BytesMutReader { buf: BytesMut, + ctx: Context, } impl AsyncRead for BytesMutReader { @@ -388,11 +633,11 @@ pub(crate) mod test_utils { } fn context(&self) -> &Context { - todo!() + &self.ctx } fn context_mut(&mut self) -> &mut Context { - todo!() + &mut self.ctx } } } diff --git a/src/tds/codec/column_data.rs b/src/tds/codec/column_data.rs index c6e78b915..259e9c586 100644 --- a/src/tds/codec/column_data.rs +++ b/src/tds/codec/column_data.rs @@ -24,6 +24,26 @@ mod udt; mod var_len; mod xml; +/// Upper bound on how many bytes a value decoder will *pre-allocate* from a +/// server-supplied length field before it has read the corresponding data. +/// +/// The wire length is untrusted: a malformed or hostile server can claim a +/// value is up to `u32::MAX`/`u64::MAX` bytes long. Reserving that up front is a +/// memory-exhaustion vector (and, for `u64` lengths, can even exceed `Vec`'s +/// `isize::MAX` capacity limit and panic). Decoders therefore cap the initial +/// reservation to this value and let the buffer grow as bytes actually arrive; +/// a short/lying length still fails cleanly when the read runs out of input. +pub(crate) const MAX_PREALLOC: usize = 8192; // 8 KiB + +/// Absolute ceiling on the *total* size of a single PLP (partially +/// length-prefixed) value — `varchar(max)`, `nvarchar(max)`, `varbinary(max)`, +/// `xml`, and CLR UDTs. SQL Server's own MAX types top out at `2^31 - 1` bytes, +/// so any value that would grow past this is malformed. Without this bound the +/// "unknown length" PLP form (which streams an arbitrary number of chunks until +/// a zero-length terminator) lets a hostile server grow the accumulation buffer +/// without limit and OOM the client on a single column value. +pub(crate) const MAX_PLP_SIZE: usize = i32::MAX as usize; + use super::{Encode, FixedLenType, TypeInfo, VarLenType}; #[cfg(feature = "tds73")] use crate::tds::time::{Date, DateTime2, DateTimeOffset, Time}; @@ -830,9 +850,34 @@ impl<'a> Encode> for ColumnData<'a> { if ty == &VarLenType::Numericn || ty == &VarLenType::Decimaln => { if let Some(num) = opt { - if scale != &num.scale() { - todo!("this still need some work, if client scale not aligned with server, we need to do conversion but will lose precision") - } + // The value is sent at the column's scale (the scale lives in + // the TYPE_INFO, not the value), so rescale when the client + // value's scale differs from the target column's scale. + let target_scale = *scale; + let num = if target_scale == num.scale() { + num + } else if target_scale > num.scale() { + // Scale up: multiply, checking for i128 overflow. + let factor = 10i128.pow((target_scale - num.scale()) as u32); + let value = num.value().checked_mul(factor).ok_or_else(|| { + crate::Error::Conversion( + "numeric value overflows when scaling to the column's scale".into(), + ) + })?; + Numeric::new_with_scale(value, target_scale) + } else { + // Scale down: divide, rounding half away from zero + // (this loses precision beyond the column's scale). + let factor = 10i128.pow((num.scale() - target_scale) as u32); + let half = factor / 2; + let v = num.value(); + let value = if v >= 0 { + (v + half) / factor + } else { + (v - half) / factor + }; + Numeric::new_with_scale(value, target_scale) + }; num.encode(&mut *dst)?; } else { dst.put_u8(0); @@ -926,6 +971,54 @@ mod tests { .expect_err("decode must consume entire buffer"); } + #[test] + fn type_name_maps_each_variant() { + assert_eq!(ColumnData::U8(Some(1)).type_name(), "tinyint"); + assert_eq!(ColumnData::I16(Some(1)).type_name(), "smallint"); + assert_eq!(ColumnData::I32(Some(1)).type_name(), "int"); + assert_eq!(ColumnData::I64(Some(1)).type_name(), "bigint"); + assert_eq!(ColumnData::F32(Some(1.0)).type_name(), "float(24)"); + assert_eq!(ColumnData::F64(Some(1.0)).type_name(), "float(53)"); + assert_eq!(ColumnData::Bit(Some(true)).type_name(), "bit"); + assert_eq!(ColumnData::Guid(None).type_name(), "uniqueidentifier"); + assert_eq!(ColumnData::Numeric(None).type_name(), "numeric"); + assert_eq!(ColumnData::DateTime(None).type_name(), "datetime"); + assert_eq!(ColumnData::SmallDateTime(None).type_name(), "smalldatetime"); + } + + #[test] + fn type_name_string_length_thresholds() { + // None and anything up to 4000 chars is a sized nvarchar; just past it + // becomes nvarchar(max). The `<= 4000` and `<= MAX_NVARCHAR_SIZE` guards + // each flip the answer at their boundary. + assert_eq!(ColumnData::String(None).type_name(), "nvarchar(4000)"); + assert_eq!( + ColumnData::String(Some("a".repeat(100).into())).type_name(), + "nvarchar(4000)" + ); + assert_eq!( + ColumnData::String(Some("a".repeat(4000).into())).type_name(), + "nvarchar(4000)" + ); + assert_eq!( + ColumnData::String(Some("a".repeat(4001).into())).type_name(), + "nvarchar(max)" + ); + } + + #[test] + fn type_name_binary_length_threshold() { + assert_eq!( + ColumnData::Binary(Some(vec![0u8; 8000].into())).type_name(), + "varbinary(8000)" + ); + assert_eq!( + ColumnData::Binary(Some(vec![0u8; 8001].into())).type_name(), + "varbinary(max)" + ); + assert_eq!(ColumnData::Binary(None).type_name(), "varbinary(max)"); + } + #[test] #[cfg(feature = "serde")] fn serde_json_round_trip() { @@ -1666,4 +1759,345 @@ mod tests { let dt2 = DateTime2::new(Date::new(0), Time::new(0, 7)); assert!(datetime2_to_datetime(&dt2).is_err()); } + + // ----- helpers for the coverage tests below ----- + + fn encode_with_ti(ti: &TypeInfo, d: ColumnData<'_>) -> crate::Result { + let mut buf = BytesMut::new(); + { + let mut b = BytesMutWithTypeInfo::new(&mut buf).with_type_info(ti); + d.encode(&mut b)?; + } + Ok(buf) + } + + fn encode_without_ti(d: ColumnData<'_>) -> crate::Result { + let mut buf = BytesMut::new(); + { + let mut b = BytesMutWithTypeInfo::new(&mut buf); + d.encode(&mut b)?; + } + Ok(buf) + } + + fn expect_bulk_input(ti: TypeInfo, d: ColumnData<'_>) { + let mut buf = BytesMut::new(); + let mut b = BytesMutWithTypeInfo::new(&mut buf).with_type_info(&ti); + let err = d.encode(&mut b).expect_err("encode should fail"); + assert!(matches!(err, Error::BulkInput(_)), "got {:?}", err); + } + + // NOTE: the `ntext(max)` catch-all in type_name is only reached by a string + // longer than MAX_NVARCHAR_SIZE (>1 GiB); allocating that in a unit test is + // impractical (slow / OOM-prone in CI), so that single line is intentionally + // left uncovered. + + // ----- decode: line 199 (VarLenSizedPrecision non-numeric -> todo!()) ----- + + #[tokio::test] + #[should_panic] + async fn decode_varlen_sized_precision_unsupported_panics() { + let ti = TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Money, + size: 8, + precision: 0, + scale: 0, + }; + let buf = BytesMut::new(); + let reader = &mut buf.into_sql_read_bytes(); + let _ = ColumnData::decode(reader, &ti).await; + } + + // ----- decode: line 202 (Udt) ----- + + #[tokio::test] + async fn decode_udt_type_info() { + use bytes::BufMut; + + let ti = TypeInfo::Udt(crate::tds::codec::type_info::UdtInfo { + max_byte_size: 0xffff, + db_name: "db".into(), + schema_name: "dbo".into(), + type_name: "geometry".into(), + assembly_qualified_name: String::new(), + }); + + let mut buf = BytesMut::new(); + // PLP unknown-length sentinel + one chunk + terminator. + buf.put_u64_le(0xfffffffffffffffe); + buf.put_u32_le(4); + buf.extend_from_slice(&[1, 2, 3, 4]); + buf.put_u32_le(0); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert_eq!(nd, ColumnData::Binary(Some(vec![1, 2, 3, 4].into()))); + } + + // ----- F64 with Money (lines 376-383) ----- + + #[tokio::test] + async fn f64_with_varlen_money() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 8, None)), + ColumnData::F64(Some(3.5)), + ) + .await; + } + + #[tokio::test] + async fn none_f64_with_varlen_money() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 8, None)), + ColumnData::F64(None), + ) + .await; + } + + // ----- BigChar error paths (lines 426, 430-437) ----- + + #[tokio::test] + async fn bigchar_unrepresentable_character_errors() { + let ti = TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::BigChar, + 40, + Some(Collation::new(13632521, 52)), + )); + let mut buf = BytesMut::new(); + let mut b = BytesMutWithTypeInfo::new(&mut buf).with_type_info(&ti); + // An emoji has no representation in the (single-byte) column collation. + let err = ColumnData::String(Some("\u{1F600}".into())) + .encode(&mut b) + .expect_err("encode should fail"); + assert!(matches!(err, Error::Encoding(_)), "got {:?}", err); + } + + #[tokio::test] + async fn bigchar_too_long_errors() { + expect_bulk_input( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::BigChar, + 2, + Some(Collation::new(13632521, 52)), + )), + ColumnData::String(Some("aaa".into())), + ); + } + + // ----- NVarchar error paths (lines 481-488 small, 513-520 unknown-size) ----- + + #[tokio::test] + async fn nvarchar_too_long_small_errors() { + expect_bulk_input( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::NVarchar, + 2, + Some(Collation::new(13632521, 52)), + )), + ColumnData::String(Some("aaa".into())), + ); + } + + #[tokio::test] + async fn nvarchar_too_long_unknown_size_errors() { + // vlc.len() == 0xffff drives the unknown-size path; a string whose UTF-16 + // byte length exceeds the column limit trips the check at 513-520. + expect_bulk_input( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::NVarchar, + 0xffff, + Some(Collation::new(13632521, 52)), + )), + ColumnData::String(Some("a".repeat(40_000).into())), + ); + } + + // ----- Text / NText encode arms (lines 538-587) ----- + + #[tokio::test] + async fn string_with_varlen_text() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::Text, + 40, + Some(Collation::new(13632521, 52)), + )), + ColumnData::String(Some("hello".into())), + ) + .await; + } + + #[tokio::test] + async fn none_string_with_varlen_text() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::Text, + 40, + Some(Collation::new(13632521, 52)), + )), + ColumnData::String(None), + ) + .await; + } + + #[tokio::test] + async fn string_with_varlen_ntext() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NText, 40, None)), + ColumnData::String(Some("hi".into())), + ) + .await; + } + + #[tokio::test] + async fn none_string_with_varlen_ntext() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NText, 40, None)), + ColumnData::String(None), + ) + .await; + } + + // ----- Binary too long (lines 650-657) ----- + + #[tokio::test] + async fn binary_too_long_errors() { + expect_bulk_input( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigVarBin, 2, None)), + ColumnData::Binary(Some(b"aaa".as_slice().into())), + ); + } + + // ----- DateTime / SmallDateTime encode without TypeInfo (714-716, 734-736) ----- + + #[tokio::test] + async fn datetime_encode_without_type_info() { + let buf = encode_without_ti(ColumnData::DateTime(Some(DateTime::new(200, 3000)))).unwrap(); + assert_eq!(buf[0], VarLenType::Datetimen as u8); + assert_eq!(buf[1], 8); + assert_eq!(buf[2], 8); + } + + #[tokio::test] + async fn smalldatetime_encode_without_type_info() { + let buf = encode_without_ti(ColumnData::SmallDateTime(Some(SmallDateTime::new( + 200, 3000, + )))) + .unwrap(); + assert_eq!(buf[0], VarLenType::Datetimen as u8); + assert_eq!(buf[1], 4); + assert_eq!(buf[2], 4); + } + + // ----- DateTime2 into a `datetime` (Datetimen) column (lines 774-780) ----- + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn datetime2_with_varlen_datetimen() { + let ti = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Datetimen, 8, None)); + // 2020-01-01 is representable by `datetime` (>= 1900-01-01). + let dt2 = DateTime2::new(Date::new(737_425), Time::new(0, 7)); + let buf = encode_with_ti(&ti, ColumnData::DateTime2(Some(dt2))).unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert!(matches!(nd, ColumnData::DateTime(Some(_))), "got {:?}", nd); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn none_datetime2_with_varlen_datetimen() { + let ti = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Datetimen, 8, None)); + let buf = encode_with_ti(&ti, ColumnData::DateTime2(None)).unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + // Just needs to decode a null cleanly. + ColumnData::decode(reader, &ti).await.unwrap(); + } + + // ----- Numeric into a Money column (lines 840-847) ----- + + #[tokio::test] + async fn numeric_with_varlen_money() { + let ti = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 8, None)); + // Numeric 3.5 (value 35000, scale 4) -> money. + let buf = encode_with_ti( + &ti, + ColumnData::Numeric(Some(Numeric::new_with_scale(35000, 4))), + ) + .unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert_eq!(nd, ColumnData::F64(Some(3.5))); + } + + #[tokio::test] + async fn none_numeric_with_varlen_money() { + let ti = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 8, None)); + let buf = encode_with_ti(&ti, ColumnData::Numeric(None)).unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert_eq!(nd, ColumnData::F64(None)); + } + + // ----- Numeric rescale on encode (lines 859-879) ----- + + #[tokio::test] + async fn numeric_scaled_up_to_column_scale() { + // Column scale 2 > value scale 0: value 23 -> 2300 at scale 2. + let ti = TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Numericn, + size: 17, + precision: 18, + scale: 2, + }; + let buf = encode_with_ti( + &ti, + ColumnData::Numeric(Some(Numeric::new_with_scale(23, 0))), + ) + .unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert_eq!( + nd, + ColumnData::Numeric(Some(Numeric::new_with_scale(2300, 2))) + ); + } + + #[tokio::test] + async fn numeric_scaled_down_to_column_scale() { + // Column scale 2 < value scale 4: 1.2345 -> 1.23 (rounded half away). + let ti = TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Numericn, + size: 17, + precision: 18, + scale: 2, + }; + let buf = encode_with_ti( + &ti, + ColumnData::Numeric(Some(Numeric::new_with_scale(12345, 4))), + ) + .unwrap(); + + let reader = &mut buf.into_sql_read_bytes(); + let nd = ColumnData::decode(reader, &ti).await.unwrap(); + assert_eq!( + nd, + ColumnData::Numeric(Some(Numeric::new_with_scale(123, 2))) + ); + } + + // ----- SQL_VARIANT encode (lines 897-898) ----- + + #[tokio::test] + async fn ssvariant_round_trip_i32() { + test_round_trip( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::SSVariant, 0, None)), + ColumnData::I32(Some(42)), + ) + .await; + } } diff --git a/src/tds/codec/column_data/bit.rs b/src/tds/codec/column_data/bit.rs index 24f4b379c..bad93ae38 100644 --- a/src/tds/codec/column_data/bit.rs +++ b/src/tds/codec/column_data/bit.rs @@ -18,3 +18,43 @@ where Ok(res) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + // At the boundary value 0 the comparison must be strictly `> 0`: a stored + // byte of 0 is `false`. Mutating `>` to `>=` would decode 0 as `true`. + #[tokio::test] + async fn decode_zero_byte_is_false() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // length + buf.put_u8(0); // value byte + + let data = decode(&mut buf.into_sql_read_bytes()).await.unwrap(); + assert_eq!(data, ColumnData::Bit(Some(false))); + } + + #[tokio::test] + async fn decode_nonzero_byte_is_true() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // length + buf.put_u8(1); // value byte + + let data = decode(&mut buf.into_sql_read_bytes()).await.unwrap(); + assert_eq!(data, ColumnData::Bit(Some(true))); + } + + // A length prefix other than 0 or 1 is an invalid bit encoding. Covers + // the error arm at lines 12-16. + #[tokio::test] + async fn decode_invalid_length_errors() { + let mut buf = BytesMut::new(); + buf.put_u8(2); // invalid length + + let err = decode(&mut buf.into_sql_read_bytes()).await.unwrap_err(); + assert!(matches!(err, Error::Protocol(_)), "got {err:?}"); + } +} diff --git a/src/tds/codec/column_data/datetime2.rs b/src/tds/codec/column_data/datetime2.rs index 7bc1fa262..f1045ab92 100644 --- a/src/tds/codec/column_data/datetime2.rs +++ b/src/tds/codec/column_data/datetime2.rs @@ -9,10 +9,41 @@ where let date = match rlen { 0 => ColumnData::DateTime2(None), rlen => { - let dt = DateTime2::decode(src, len, rlen as usize - 3).await?; + // A datetime2 value is a `time` portion (rlen - 3 bytes) followed by + // a 3-byte `date`. A server-supplied rlen < 3 would underflow. + let time_len = (rlen as usize).checked_sub(3).ok_or_else(|| { + crate::Error::Protocol(format!("datetime2: invalid value length {rlen}").into()) + })?; + let dt = DateTime2::decode(src, len, time_len).await?; ColumnData::DateTime2(Some(dt)) } }; Ok(date) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::BytesMut; + + #[tokio::test] + async fn rejects_underlength_value_instead_of_panicking() { + // rlen is 1 (non-NULL but < 3): must be a protocol error, not a panic. + let mut buf = BytesMut::new(); + buf.extend_from_slice(&[1u8]); + let err = decode(&mut buf.into_sql_read_bytes(), 8) + .await + .expect_err("rlen < 3 must be rejected"); + assert!(matches!(err, crate::Error::Protocol(_))); + } + + #[tokio::test] + async fn zero_length_is_null() { + let mut buf = BytesMut::new(); + buf.extend_from_slice(&[0u8]); + let v = decode(&mut buf.into_sql_read_bytes(), 8).await.unwrap(); + assert!(matches!(v, ColumnData::DateTime2(None))); + } +} diff --git a/src/tds/codec/column_data/datetimeoffsetn.rs b/src/tds/codec/column_data/datetimeoffsetn.rs index d695f3495..fd77be2c7 100644 --- a/src/tds/codec/column_data/datetimeoffsetn.rs +++ b/src/tds/codec/column_data/datetimeoffsetn.rs @@ -9,10 +9,35 @@ where let dto = match rlen { 0 => ColumnData::DateTimeOffset(None), _ => { - let dto = DateTimeOffset::decode(src, len, rlen - 5).await?; + // A datetimeoffset value is a `time` portion (rlen - 5 bytes) then a + // 3-byte `date` and a 2-byte offset. A server rlen < 5 would underflow. + let time_len = rlen.checked_sub(5).ok_or_else(|| { + crate::Error::Protocol( + format!("datetimeoffset: invalid value length {rlen}").into(), + ) + })?; + let dto = DateTimeOffset::decode(src, len, time_len).await?; ColumnData::DateTimeOffset(Some(dto)) } }; Ok(dto) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::BytesMut; + + #[tokio::test] + async fn rejects_underlength_value_instead_of_panicking() { + // rlen in 1..=4 (non-NULL but < 5): must be a protocol error, not a panic. + let mut buf = BytesMut::new(); + buf.extend_from_slice(&[4u8]); + let err = decode(&mut buf.into_sql_read_bytes(), 8) + .await + .expect_err("rlen < 5 must be rejected"); + assert!(matches!(err, crate::Error::Protocol(_))); + } +} diff --git a/src/tds/codec/column_data/fixed_len.rs b/src/tds/codec/column_data/fixed_len.rs index 26f560cdf..691c36998 100644 --- a/src/tds/codec/column_data/fixed_len.rs +++ b/src/tds/codec/column_data/fixed_len.rs @@ -8,7 +8,13 @@ where R: SqlReadBytes + Unpin, { let data = match r#type { - FixedLenType::Null => ColumnData::Bit(None), + // Wire type 0x1F (MS-TDS 2.2.5.4.1) carries no data and represents a + // typeless NULL. Surface it as `I32(None)` to match both the NBCROW + // packed-null path (`BaseMetaDataColumn::null_value`) and the column's + // own `Display` ("int"); previously this ROW path returned `Bit(None)`, + // so the same `SELECT NULL` column decoded to a different variant + // depending on whether the server packed the row. + FixedLenType::Null => ColumnData::I32(None), FixedLenType::Bit => ColumnData::Bit(Some(src.read_u8().await? != 0)), FixedLenType::Int1 => ColumnData::U8(Some(src.read_u8().await?)), FixedLenType::Int2 => ColumnData::I16(Some(src.read_i16_le().await?)), @@ -24,3 +30,22 @@ where Ok(data) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::BytesMut; + + #[tokio::test] + async fn null_decodes_as_i32_none() { + // FixedLenType::Null (0x1F) carries no bytes and must decode to + // `I32(None)`, consistent with the NBCROW `null_value()` path and the + // column's `Display` ("int"). + let buf = BytesMut::new(); + let data = decode(&mut buf.into_sql_read_bytes(), &FixedLenType::Null) + .await + .expect("null decode must succeed"); + assert!(matches!(data, ColumnData::I32(None))); + } +} diff --git a/src/tds/codec/column_data/image.rs b/src/tds/codec/column_data/image.rs index 22a12ebe2..635e931b9 100644 --- a/src/tds/codec/column_data/image.rs +++ b/src/tds/codec/column_data/image.rs @@ -18,7 +18,8 @@ where src.read_u32_le().await?; // second fractions let len = src.read_u32_le().await? as usize; - let mut buf = Vec::with_capacity(len); + // `len` is untrusted; cap the up-front reservation (see MAX_PREALLOC). + let mut buf = Vec::with_capacity(len.min(super::MAX_PREALLOC)); for _ in 0..len { buf.push(src.read_u8().await?); @@ -26,3 +27,34 @@ where Ok(ColumnData::Binary(Some(buf.into()))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn decode_null_when_ptr_len_zero() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let data = decode(&mut buf.into_sql_read_bytes()).await.unwrap(); + assert_eq!(data, ColumnData::Binary(None)); + } + + #[tokio::test] + async fn decode_reads_pointer_timestamp_and_payload() { + let mut buf = BytesMut::new(); + buf.put_u8(2); // ptr_len + buf.put_u8(0xAA); + buf.put_u8(0xBB); // pointer bytes (ignored) + buf.put_i32_le(0); // days (ignored) + buf.put_u32_le(0); // second fractions (ignored) + buf.put_u32_le(3); // payload len + buf.put_slice(&[1, 2, 3]); + + let data = decode(&mut buf.into_sql_read_bytes()).await.unwrap(); + assert_eq!(data, ColumnData::Binary(Some(vec![1, 2, 3].into()))); + } +} diff --git a/src/tds/codec/column_data/money.rs b/src/tds/codec/column_data/money.rs index 089ebe412..1cf754c1a 100644 --- a/src/tds/codec/column_data/money.rs +++ b/src/tds/codec/column_data/money.rs @@ -48,6 +48,42 @@ where #[cfg(test)] mod tests { use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + // `smallmoney` (len 4) is a single scaled `i32` divided by 1e4. Uses a value + // that is not a boundary: kills "delete match arm 4" (would fall through to + // the error arm) and "replace `/` with `%`/`*`" (12345/1e4 = 1.2345, whereas + // 12345 % 1e4 = 2345.0 and 12345 * 1e4 = 1.2345e8). + #[tokio::test] + async fn decode_smallmoney_arm() { + let mut buf = BytesMut::new(); + buf.put_i32_le(12345); + + let data = decode(&mut buf.into_sql_read_bytes(), 4).await.unwrap(); + match data { + ColumnData::F64(Some(v)) => assert!((v - 1.2345).abs() < 1e-9, "v={}", v), + other => panic!("expected F64, got {:?}", other), + } + } + + // `money` (len 8) is two 32-bit words: `((high << 32) + low) / 1e4`. + // high = 1, low = 30000 gives ((1 << 32) + 30000) / 1e4 = 429499.7296. + // Kills: "delete match arm 8" (error fallthrough); "replace `<<` with `>>`" + // (1 >> 32 = 0 => 3.0); "replace `+` with `-`/`*`" (subtraction/mult differ); + // and "replace outer `/` with `%`/`*`" (4294997296 % 1e4 = 7296.0). + #[tokio::test] + async fn decode_money_arm() { + let mut buf = BytesMut::new(); + buf.put_i32_le(1); // high word + buf.put_u32_le(30000); // low word + + let data = decode(&mut buf.into_sql_read_bytes(), 8).await.unwrap(); + match data { + ColumnData::F64(Some(v)) => assert!((v - 429499.7296).abs() < 1e-6, "v={}", v), + other => panic!("expected F64, got {:?}", other), + } + } // Reverses the on-wire money representation the same way `decode` does, // so we can assert `encode` is the exact inverse without a live server. @@ -73,6 +109,23 @@ mod tests { assert_eq!(decode_bytes(&buf), 1234.5678); } + // A length other than 0/4/8 is an invalid money encoding. Covers 38-42. + #[tokio::test] + async fn decode_invalid_length_errors() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + let err = decode(&mut buf.into_sql_read_bytes(), 5).await.unwrap_err(); + assert!(matches!(err, Error::Protocol(_)), "got {err:?}"); + } + + // The `decode_bytes` test helper panics on an unexpected length prefix. + // Covers line 99 (the helper's fallthrough arm). + #[test] + fn decode_bytes_invalid_length_panics() { + let result = std::panic::catch_unwind(|| decode_bytes(&[2u8, 0, 0, 0, 0])); + assert!(result.is_err()); + } + #[test] fn encode_money_roundtrips() { for val in [0.0, 1.0, -1.0, 1234.5678, -9999.9999, 92233720368.5477] { diff --git a/src/tds/codec/column_data/plp.rs b/src/tds/codec/column_data/plp.rs index 2c7fdb7e3..e2d133801 100644 --- a/src/tds/codec/column_data/plp.rs +++ b/src/tds/codec/column_data/plp.rs @@ -1,6 +1,11 @@ use crate::sql_read_bytes::SqlReadBytes; // Decode a partially length-prefixed type. +// +// NOTE: values are read via the packet-aware `read_u8`/`read_u16_le`/`read_u32_le` +// helpers (which transparently span TDS packet boundaries). The generic +// `AsyncReadExt::read_exact` must NOT be used here: a PLP value can span multiple +// packets, and `read_exact` treats a packet-boundary `Ok(0)` as EOF. pub(crate) async fn decode(src: &mut R, len: usize) -> crate::Result>> where R: SqlReadBytes + Unpin, @@ -8,13 +13,13 @@ where match len { // Fixed size len if len < 0xffff => { - let len = src.read_u16_le().await? as u64; + let len = src.read_u16_le().await? as usize; match len { // NULL 0xffff => Ok(None), _ => { - let mut data = Vec::with_capacity(len as usize); + let mut data = Vec::with_capacity(len.min(super::MAX_PREALLOC)); for _ in 0..len { data.push(src.read_u8().await?); @@ -33,11 +38,13 @@ where 0xffffffffffffffff => return Ok(None), // Unknown size 0xfffffffffffffffe => Vec::new(), - // Known size - _ => Vec::with_capacity(len as usize), + // Known size. `len` is an untrusted 64-bit wire value; cap the + // up-front reservation (avoids memory-exhaustion and the + // `Vec` capacity-overflow panic for values near u64::MAX). + _ => Vec::with_capacity((len as usize).min(super::MAX_PREALLOC)), }; - let mut chunk_data_left = 0; + let mut chunk_data_left = 0usize; loop { if chunk_data_left == 0 { @@ -46,11 +53,25 @@ where if chunk_size == 0 { break; // found a sentinel, we're done - } else { - chunk_data_left = chunk_size } + + // The number of chunks in an "unknown length" PLP value is + // unbounded on the wire. Cap the running total so a hostile + // server cannot stream chunks forever and exhaust memory on + // a single value. + if data.len().saturating_add(chunk_size) > super::MAX_PLP_SIZE { + return Err(crate::Error::Protocol( + format!( + "PLP value exceeds the maximum supported size of {} bytes", + super::MAX_PLP_SIZE + ) + .into(), + )); + } + + chunk_data_left = chunk_size; } else { - // Just read a byte + // Read a byte (packet-aware). let byte = src.read_u8().await?; chunk_data_left -= 1; @@ -62,3 +83,58 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + // The `len` argument selects the wire layout: `len < 0xffff` means a plain + // `u16`-prefixed value, otherwise a `u64`-prefixed chunked (PLP) value. At + // the boundary `len == 0xffff` the real code takes the chunked branch + // (reads a `u64` length). Mutating `<` to `<=` would take the fixed branch + // (reads a `u16` length) and produce a different value. We feed a chunked + // stream that decodes to [0xAA, 0xBB, 0xCC]; under the `<=` mutant the same + // bytes are misread as a `u16` length of 5 followed by five zero bytes. + #[tokio::test] + async fn decode_boundary_len_uses_chunked_branch() { + let mut buf = BytesMut::new(); + buf.put_u64_le(5); // known-size PLP total length + buf.put_u32_le(3); // chunk size + buf.put_slice(&[0xAA, 0xBB, 0xCC]); + buf.put_u32_le(0); // terminating chunk + + let data = decode(&mut buf.into_sql_read_bytes(), 0xffff) + .await + .unwrap(); + assert_eq!(data, Some(vec![0xAA, 0xBB, 0xCC])); + } + + // A chunk whose running total strictly exceeds `MAX_PLP_SIZE` must be + // rejected with the "exceeds the maximum" protocol error *before* any chunk + // data is read. Mutating `>` to `==` would let a strictly-greater total slip + // past the guard (the `==` only fires at the exact boundary), so instead of + // the protocol error the decoder would try to read a ~4 GiB chunk and fail + // with an unrelated read error. + #[tokio::test] + async fn decode_oversized_chunk_is_rejected() { + let mut buf = BytesMut::new(); + buf.put_u64_le(10); // known-size marker -> chunked branch bookkeeping + buf.put_u32_le(u32::MAX); // chunk size well past MAX_PLP_SIZE + + let err = decode(&mut buf.into_sql_read_bytes(), 0x10000) + .await + .expect_err("oversized PLP chunk must be rejected"); + + match err { + crate::Error::Protocol(msg) => { + assert!( + msg.contains("exceeds the maximum"), + "unexpected protocol message: {msg}" + ); + } + other => panic!("expected a protocol error, got {other:?}"), + } + } +} diff --git a/src/tds/codec/column_data/string.rs b/src/tds/codec/column_data/string.rs index 3a38794d4..4c18ffc68 100644 --- a/src/tds/codec/column_data/string.rs +++ b/src/tds/codec/column_data/string.rs @@ -1,7 +1,5 @@ use std::borrow::Cow; -use byteorder::{ByteOrder, LittleEndian}; - use crate::{error::Error, sql_read_bytes::SqlReadBytes, tds::Collation, VarLenType}; pub(crate) async fn decode( @@ -36,8 +34,14 @@ where return Err(Error::Protocol("nvarchar: invalid plp length".into())); } - let buf: Vec<_> = buf.chunks(2).map(LittleEndian::read_u16).collect(); - Ok(Some(String::from_utf16(&buf)?.into())) + // Decode UTF-16LE straight from the byte pairs, without first + // collecting an intermediate `Vec` (one fewer full-buffer + // allocation + copy per value). Invalid surrogates still error, + // matching the previous `String::from_utf16` behaviour. + let s = char::decode_utf16(buf.chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]]))) + .collect::>() + .map_err(|_| Error::Protocol("nvarchar: invalid UTF-16 sequence".into()))?; + Ok(Some(s.into())) } _ => Ok(None), } diff --git a/src/tds/codec/column_data/text.rs b/src/tds/codec/column_data/text.rs index 0c454d251..e9ba93679 100644 --- a/src/tds/codec/column_data/text.rs +++ b/src/tds/codec/column_data/text.rs @@ -25,7 +25,7 @@ where Some(collation) => { let encoder = collation.encoding()?; let text_len = src.read_u32_le().await? as usize; - let mut buf = Vec::with_capacity(text_len); + let mut buf = Vec::with_capacity(text_len.min(super::MAX_PREALLOC)); for _ in 0..text_len { buf.push(src.read_u8().await?); @@ -39,7 +39,8 @@ where // NTEXT None => { let text_len = src.read_u32_le().await? as usize / 2; - let mut buf = Vec::with_capacity(text_len); + // u16 elements; cap the reservation to MAX_PREALLOC bytes' worth. + let mut buf = Vec::with_capacity(text_len.min(super::MAX_PREALLOC / 2)); for _ in 0..text_len { buf.push(src.read_u16_le().await?); @@ -51,3 +52,51 @@ where Ok(ColumnData::String(Some(text.into()))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn decode_null_when_ptr_len_zero() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let data = decode(&mut buf.into_sql_read_bytes(), None).await.unwrap(); + assert_eq!(data, ColumnData::String(None)); + } + + #[tokio::test] + async fn decode_ntext_reads_utf16_payload() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // ptr_len + buf.put_u8(0xAA); // pointer byte (ignored) + buf.put_i32_le(0); // days + buf.put_u32_le(0); // second fractions + buf.put_u32_le(4); // byte length of the UTF-16 text (2 chars) + buf.put_u16_le('h' as u16); + buf.put_u16_le('i' as u16); + + let data = decode(&mut buf.into_sql_read_bytes(), None).await.unwrap(); + assert_eq!(data, ColumnData::String(Some("hi".into()))); + } + + #[tokio::test] + async fn decode_text_uses_collation_encoding() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // ptr_len + buf.put_u8(0xAA); + buf.put_i32_le(0); + buf.put_u32_le(0); + buf.put_u32_le(2); // 2 raw bytes in codepage encoding + buf.put_slice(b"hi"); + + let collation = crate::tds::Collation::new(0x0409, 0); // WINDOWS_1252 + let data = decode(&mut buf.into_sql_read_bytes(), Some(collation)) + .await + .unwrap(); + assert_eq!(data, ColumnData::String(Some("hi".into()))); + } +} diff --git a/src/tds/codec/column_data/var_len.rs b/src/tds/codec/column_data/var_len.rs index ff8be56ff..c91e93819 100644 --- a/src/tds/codec/column_data/var_len.rs +++ b/src/tds/codec/column_data/var_len.rs @@ -1,4 +1,6 @@ -use crate::{sql_read_bytes::SqlReadBytes, tds::codec::VarLenContext, ColumnData, VarLenType}; +use crate::{ + sql_read_bytes::SqlReadBytes, tds::codec::VarLenContext, ColumnData, Error, VarLenType, +}; pub(crate) async fn decode( src: &mut R, @@ -42,8 +44,133 @@ where NText => super::text::decode(src, None).await?, Image => super::image::decode(src).await?, SSVariant => super::sql_variant::decode(src).await?, - t => unimplemented!("{:?}", t), + t => { + return Err(Error::Protocol( + format!("unsupported column type: {:?}", t).into(), + )) + } }; Ok(res) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn decode_bitn_true() { + let mut buf = BytesMut::new(); + buf.put_u8(1); // recv_len + buf.put_u8(1); // true + + let ctx = VarLenContext::new(VarLenType::Bitn, 1, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::Bit(Some(true))); + } + + #[tokio::test] + async fn decode_intn_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); // recv_len 0 -> null + + let ctx = VarLenContext::new(VarLenType::Intn, 4, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::I32(None)); + } + + #[tokio::test] + async fn decode_guid_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let ctx = VarLenContext::new(VarLenType::Guid, 16, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::Guid(None)); + } + + #[tokio::test] + async fn decode_nvarchar_value() { + let mut buf = BytesMut::new(); + buf.put_u16_le(4); // 4 bytes of UTF-16 + buf.put_u16_le('a' as u16); + buf.put_u16_le('b' as u16); + + let ctx = VarLenContext::new(VarLenType::NVarchar, 100, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::String(Some("ab".into()))); + } + + #[tokio::test] + async fn decode_money_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); // len byte read inside decode() + + let ctx = VarLenContext::new(VarLenType::Money, 8, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::F64(None)); + } + + #[tokio::test] + async fn decode_datetimen_null_smalldatetime() { + let mut buf = BytesMut::new(); + buf.put_u8(0); // rlen == 0 + + let ctx = VarLenContext::new(VarLenType::Datetimen, 4, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::SmallDateTime(None)); + } + + #[tokio::test] + async fn decode_text_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); // ptr_len 0 -> null + + let ctx = VarLenContext::new(VarLenType::Text, 0, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::String(None)); + } + + #[tokio::test] + async fn decode_ntext_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let ctx = VarLenContext::new(VarLenType::NText, 0, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::String(None)); + } + + #[tokio::test] + async fn decode_image_null() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let ctx = VarLenContext::new(VarLenType::Image, 0, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::Binary(None)); + } + + #[tokio::test] + async fn decode_ssvariant_null() { + let mut buf = BytesMut::new(); + buf.put_u32_le(0); // total_len 0 -> null + + let ctx = VarLenContext::new(VarLenType::SSVariant, 0, None); + let data = decode(&mut buf.into_sql_read_bytes(), &ctx).await.unwrap(); + assert_eq!(data, ColumnData::String(None)); + } + + #[tokio::test] + async fn decode_unsupported_type_errors() { + let buf = BytesMut::new(); + + let ctx = VarLenContext::new(VarLenType::Udt, 0, None); + let err = decode(&mut buf.into_sql_read_bytes(), &ctx) + .await + .unwrap_err(); + assert!(format!("{}", err).contains("unsupported column type")); + } +} diff --git a/src/tds/codec/decode.rs b/src/tds/codec/decode.rs index b97766833..e9ab19a8b 100644 --- a/src/tds/codec/decode.rs +++ b/src/tds/codec/decode.rs @@ -59,3 +59,125 @@ impl Decoder for PacketCodec { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::codec::{Encode, PacketHeader, PacketType}; + + // A full SQLBatch packet: 8-byte header + "hello world" payload => length 19. + fn full_packet_bytes() -> BytesMut { + let payload = BytesMut::from(&b"hello world"[..]); + let packet = Packet::new(PacketHeader::batch(1), payload); + + let mut buf = BytesMut::new(); + packet.encode(&mut buf).unwrap(); + // Sanity: 8 header + 11 payload. + assert_eq!(buf.len(), 19); + buf + } + + #[test] + fn decode_partial_header_returns_none() { + // Fewer than HEADER_BYTES available: we must wait for more bytes and + // never index into `&src[0..HEADER_BYTES]`. + let mut src = BytesMut::from(&full_packet_bytes()[0..4]); + let mut codec = PacketCodec; + + let out = codec.decode(&mut src).unwrap(); + assert!(out.is_none()); + } + + #[test] + fn decode_complete_packet_returns_some() { + let mut src = full_packet_bytes(); + let mut codec = PacketCodec; + + let packet = codec + .decode(&mut src) + .unwrap() + .expect("a complete packet must decode to Some"); + + assert_eq!(packet.header.r#type() as u8, PacketType::SQLBatch as u8); + // payload length must be `length - HEADER_BYTES` = 19 - 8 = 11. + assert_eq!(&packet.payload[..], b"hello world"); + assert_eq!(packet.payload.len(), 11); + // The whole packet is consumed. + assert!(src.is_empty()); + } + + #[test] + fn decode_incomplete_body_returns_none() { + // Full header present (declares length 19) but only part of the body is + // buffered: must return None rather than splitting past the buffer end. + let mut src = BytesMut::from(&full_packet_bytes()[0..11]); + let mut codec = PacketCodec; + + let out = codec.decode(&mut src).unwrap(); + assert!(out.is_none()); + } + + #[test] + fn decode_minimal_packet_exactly_header_bytes() { + // An empty-payload packet is exactly HEADER_BYTES (8) long with a + // declared length of 8. This is the boundary for both length checks: + // `src.len() < HEADER_BYTES` and `length < HEADER_BYTES` must be false. + let packet = Packet::new(PacketHeader::attention(1), BytesMut::new()); + let mut src = BytesMut::new(); + packet.encode(&mut src).unwrap(); + assert_eq!(src.len(), 8); + + let mut codec = PacketCodec; + let packet = codec + .decode(&mut src) + .unwrap() + .expect("an 8-byte packet must decode to Some"); + + assert_eq!( + packet.header.r#type() as u8, + PacketType::AttentionSignal as u8 + ); + assert!(packet.payload.is_empty()); + } + + #[test] + fn decode_rejects_length_below_header() { + // Declare a total length smaller than the header itself. We have enough + // bytes buffered, so we reach the `length < HEADER_BYTES` guard, which + // must error rather than underflow `length - HEADER_BYTES`. + let packet = Packet::new(PacketHeader::attention(1), BytesMut::new()); + let mut src = BytesMut::new(); + packet.encode(&mut src).unwrap(); + // Overwrite the BE length field (bytes 2..4) with 5 (< HEADER_BYTES). + src[2] = 0; + src[3] = 5; + + let mut codec = PacketCodec; + let err = codec + .decode(&mut src) + .expect_err("length below header must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn decode_eof_returns_some_for_complete_packet() { + let mut src = full_packet_bytes(); + let mut codec = PacketCodec; + + let packet = codec + .decode_eof(&mut src) + .unwrap() + .expect("decode_eof must yield a complete packet"); + assert_eq!(packet.header.r#type() as u8, PacketType::SQLBatch as u8); + assert_eq!(&packet.payload[..], b"hello world"); + } + + #[test] + fn decode_eof_errors_on_trailing_partial_bytes() { + // A partial packet at EOF (no full frame, buffer not empty) is an error. + let mut src = BytesMut::from(&full_packet_bytes()[0..4]); + let mut codec = PacketCodec; + + assert!(codec.decode_eof(&mut src).is_err()); + } +} diff --git a/src/tds/codec/encode.rs b/src/tds/codec/encode.rs index 88782c5d2..4cb83e225 100644 --- a/src/tds/codec/encode.rs +++ b/src/tds/codec/encode.rs @@ -15,3 +15,26 @@ impl Encoder for PacketCodec { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::codec::{PacketHeader, PacketType}; + + #[test] + fn encode_writes_header_and_payload_to_dst() { + let payload = BytesMut::from(&b"abcd"[..]); + let packet = Packet::new(PacketHeader::batch(1), payload); + + let mut dst = BytesMut::new(); + let mut codec = PacketCodec; + codec.encode(packet, &mut dst).expect("encode must succeed"); + + // 8-byte header + 4-byte payload; a no-op encode would leave dst empty. + assert_eq!(dst.len(), 12); + assert_eq!(dst[0], PacketType::SQLBatch as u8); + // Total length is patched into the BE length field (bytes 2..4). + assert_eq!(&dst[2..4], &12u16.to_be_bytes()); + assert_eq!(&dst[8..], b"abcd"); + } +} diff --git a/src/tds/codec/guid.rs b/src/tds/codec/guid.rs index 1298997ec..5d05a0f3e 100644 --- a/src/tds/codec/guid.rs +++ b/src/tds/codec/guid.rs @@ -8,3 +8,32 @@ pub(crate) fn reorder_bytes(bytes: &mut uuid::Bytes) { bytes.swap(4, 5); bytes.swap(6, 7); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reorder_bytes_swaps_the_guid_groups() { + // Swaps within the first three groups (0<->3, 1<->2, 4<->5, 6<->7); the + // trailing 8 bytes are left in place. + let mut bytes: uuid::Bytes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + reorder_bytes(&mut bytes); + assert_eq!( + bytes, + [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15] + ); + } + + #[test] + fn reorder_bytes_is_its_own_inverse() { + let original: uuid::Bytes = [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, + ]; + let mut bytes = original; + reorder_bytes(&mut bytes); + assert_ne!(bytes, original); + reorder_bytes(&mut bytes); + assert_eq!(bytes, original); + } +} diff --git a/src/tds/codec/iterator_ext.rs b/src/tds/codec/iterator_ext.rs index aecdd6d5a..b8a160fab 100644 --- a/src/tds/codec/iterator_ext.rs +++ b/src/tds/codec/iterator_ext.rs @@ -25,3 +25,20 @@ where out } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_interleaves_separator_between_elements() { + let joined = [1, 2, 3].into_iter().join(", "); + assert_eq!(joined, "1, 2, 3"); + } + + #[test] + fn join_single_element_has_no_separator() { + let joined = std::iter::once("only").join(", "); + assert_eq!(joined, "only"); + } +} diff --git a/src/tds/codec/login.rs b/src/tds/codec/login.rs index 7351da072..96918c6de 100644 --- a/src/tds/codec/login.rs +++ b/src/tds/codec/login.rs @@ -39,7 +39,7 @@ impl FeatureLevel { pub enum OptionFlag1 { /// The byte order used by client for numeric and datetime data types. /// (default: little-endian) - BigEndian = 1 << 0, + BigEndian = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) /// The character set used on the client. (default: ASCII) CharsetEBDDIC = 1 << 1, /// Use VAX floating point representation. (default: IEEE 754) @@ -68,7 +68,7 @@ pub enum OptionFlag1 { pub enum OptionFlag2 { /// Set if the change to initial language needs to succeed if the connect is /// to succeed. - InitLangFatal = 1 << 0, + InitLangFatal = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) /// Set if the client is the ODBC driver. This causes the server to set /// `ANSI_DEFAULTS=ON`, `CURSOR_CLOSE_ON_COMMIT`, `IMPLICIT_TRANSACTIONS=OFF`, /// `TEXTSIZE=0x7FFFFFFF` (2GB) (TDS 7.2 and earlier) `TEXTSIZE` to infinite @@ -93,7 +93,7 @@ pub enum OptionFlag2 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OptionFlag3 { /// Request to change login's password. - RequestChangePassword = 1 << 0, + RequestChangePassword = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) /// XML data type instances are returned as binary XML. BinaryXML = 1 << 1, /// Client is requesting separate process to be spawned as user instance. @@ -112,7 +112,7 @@ pub enum OptionFlag3 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginTypeFlag { /// Use T-SQL syntax. - UseTSQL = 1 << 0, + UseTSQL = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) /// Set if the client is the OLEDB driver. This causes the server to set /// ANSI_DEFAULTS to ON, CURSOR_CLOSE_ON_COMMIT and IMPLICIT_TRANSACTIONS to /// OFF, TEXTSIZE to 0x7FFFFFFF (2GB) (TDS 7.2 and earlier), TEXTSIZE to @@ -130,7 +130,7 @@ pub(crate) const FEA_EXT_TERMINATOR: u8 = 0xFFu8; pub(crate) const FED_AUTH_LIBRARYSECURITYTOKEN: u8 = 0x01; /// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] struct FedAuthExt<'a> { fed_auth_echo: bool, @@ -138,8 +138,21 @@ struct FedAuthExt<'a> { nonce: Option<[u8; 32]>, } +// Manual Debug so the AAD bearer token is never printed. `LoginMessage`'s own +// Debug redacts the SQL password; this keeps the federated-auth token redacted +// too (its derived Debug would otherwise leak the full token via that field). +impl std::fmt::Debug for FedAuthExt<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FedAuthExt") + .field("fed_auth_echo", &self.fed_auth_echo) + .field("fed_auth_token", &"") + .field("nonce", &self.nonce.map(|_| "")) + .finish() + } +} + /// the login packet -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] pub struct LoginMessage<'a> { /// the highest TDS version the client supports @@ -171,6 +184,34 @@ pub struct LoginMessage<'a> { fed_auth_ext: Option>, } +// Manual Debug so the plaintext `password` is never printed (every other +// credential-bearing type in the crate redacts it the same way). +impl std::fmt::Debug for LoginMessage<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoginMessage") + .field("tds_version", &self.tds_version) + .field("packet_size", &self.packet_size) + .field("client_prog_ver", &self.client_prog_ver) + .field("client_pid", &self.client_pid) + .field("connection_id", &self.connection_id) + .field("option_flags_1", &self.option_flags_1) + .field("option_flags_2", &self.option_flags_2) + .field("integrated_security", &self.integrated_security) + .field("type_flags", &self.type_flags) + .field("option_flags_3", &self.option_flags_3) + .field("client_timezone", &self.client_timezone) + .field("client_lcid", &self.client_lcid) + .field("hostname", &self.hostname) + .field("username", &self.username) + .field("password", &"") + .field("app_name", &self.app_name) + .field("server_name", &self.server_name) + .field("db_name", &self.db_name) + .field("fed_auth_ext", &self.fed_auth_ext) + .finish() + } +} + impl<'a> LoginMessage<'a> { pub fn new() -> LoginMessage<'a> { Self { @@ -348,10 +389,11 @@ impl<'a> LoginMessage<'a> { fea_ext_offset = cursor.position(); } - // write the client ID (created from the MAC address) + // Client ID field: a fixed placeholder (not derived from the + // MAC address). SQL Server does not require a real value here. if i == 9 { - cursor.write_u32::(0)?; //TODO: - cursor.write_u16::(42)?; //TODO: generate real client id + cursor.write_u32::(0)?; + cursor.write_u16::(42)?; continue; } @@ -716,4 +758,85 @@ mod tests { assert_eq!(login, decoded); } + + #[test] + fn hostname_and_packet_size_setters_apply() { + let mut login = LoginMessage::new(); + login.hostname("my-workstation"); + login.packet_size(8192); + + assert_eq!(login.hostname, "my-workstation"); + assert_eq!(login.packet_size, 8192); + } + + #[cfg(any( + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")), + windows + ))] + #[test] + fn integrated_security_setter_toggles_flag() { + let mut login = LoginMessage::new(); + + login.integrated_security(Some(vec![1, 2, 3, 4])); + assert!(login + .option_flags_2 + .contains(OptionFlag2::IntegratedSecurity)); + assert_eq!( + login.integrated_security.as_deref(), + Some(&[1, 2, 3, 4][..]) + ); + + login.integrated_security(None); + assert!(!login + .option_flags_2 + .contains(OptionFlag2::IntegratedSecurity)); + assert!(login.integrated_security.is_none()); + } + + #[test] + fn encode_round_trips_integrated_security_bytes() { + let mut payload = BytesMut::new(); + let mut login = LoginMessage::new(); + // Set the field directly to exercise the ibSSPI encode branch without + // depending on the platform-gated setter. + login.integrated_security = Some(vec![9, 8, 7, 6, 5]); + login + .clone() + .encode(&mut payload) + .expect("encode should succeed"); + + let decoded = LoginMessage::decode(&mut payload).expect("decode should succeed"); + assert_eq!(decoded.integrated_security, Some(vec![9, 8, 7, 6, 5])); + } + + #[test] + fn fed_auth_without_nonce_round_trips() { + let mut payload = BytesMut::new(); + let mut login = LoginMessage::new(); + login.aad_token("fake-aad-token", true, None); + login + .clone() + .encode(&mut payload) + .expect("encode should succeed"); + + let decoded = LoginMessage::decode(&mut payload).expect("decode should succeed"); + assert_eq!(login, decoded); + assert_eq!( + decoded.fed_auth_ext.expect("fed auth ext present").nonce, + None + ); + } + + #[test] + fn debug_redacts_fed_auth_token() { + let mut login = LoginMessage::new(); + login.aad_token("super-secret-aad-token", true, Some([9u8; 32])); + + let dbg = format!("{login:?}"); + assert!( + !dbg.contains("super-secret-aad-token"), + "AAD token leaked in Debug output: {dbg}" + ); + assert!(dbg.contains("HIDDEN")); + } } diff --git a/src/tds/codec/packet.rs b/src/tds/codec/packet.rs index 9927ed35d..9c4e9e34a 100644 --- a/src/tds/codec/packet.rs +++ b/src/tds/codec/packet.rs @@ -55,3 +55,65 @@ impl<'a> Extend<&'a u8> for Packet { self.payload.extend(iter) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::codec::PacketHeader; + + #[test] + fn is_last_reflects_end_of_message_status() { + let mut packet = Packet::new(PacketHeader::batch(1), BytesMut::new()); + assert!(!packet.is_last()); + + packet.header.set_status(PacketStatus::EndOfMessage); + assert!(packet.is_last()); + } + + #[test] + fn into_parts_returns_header_and_payload() { + let payload = BytesMut::from(&b"hello"[..]); + let packet = Packet::new(PacketHeader::batch(3), payload.clone()); + + let (header, parts_payload) = packet.into_parts(); + assert_eq!(header.r#type() as u8, PacketHeader::batch(3).r#type() as u8); + assert_eq!(parts_payload, payload); + } + + #[test] + fn encode_patches_total_length_into_header() { + let payload = BytesMut::from(&b"abcd"[..]); + let packet = Packet::new(PacketHeader::batch(1), payload); + + let mut buf = BytesMut::new(); + packet.encode(&mut buf).unwrap(); + + // 8 header bytes + 4 payload bytes. + assert_eq!(&buf[2..4], &12u16.to_be_bytes()); + assert_eq!(&buf[8..], b"abcd"); + } + + #[test] + fn decode_splits_header_and_remaining_payload() { + let payload = BytesMut::from(&b"xyz"[..]); + let packet = Packet::new(PacketHeader::batch(1), payload); + + let mut buf = BytesMut::new(); + packet.encode(&mut buf).unwrap(); + + let decoded = Packet::decode(&mut buf).unwrap(); + assert_eq!(&decoded.payload[..], b"xyz"); + assert!(buf.is_empty()); + } + + #[test] + fn extend_by_value_and_by_ref_append_to_payload() { + let mut packet = Packet::new(PacketHeader::batch(1), BytesMut::new()); + packet.extend(vec![1u8, 2, 3]); + assert_eq!(&packet.payload[..], &[1, 2, 3]); + + let more = [4u8, 5]; + packet.extend(more.iter()); + assert_eq!(&packet.payload[..], &[1, 2, 3, 4, 5]); + } +} diff --git a/src/tds/codec/pre_login.rs b/src/tds/codec/pre_login.rs index 16ff62f95..d9016051c 100644 --- a/src/tds/codec/pre_login.rs +++ b/src/tds/codec/pre_login.rs @@ -236,7 +236,8 @@ impl Decode for PreloginMessage { // verify whether the server acts in accordance to what we requested // and if we can handle on what we seemingly agreed to - // TODO: support parsing more + // Unrecognized (e.g. newer) pre-login option tokens are skipped; + // this is intentional forward-compatibility, not a bug. match token { // version PRELOGIN_VERSION => { @@ -424,6 +425,146 @@ mod tests { assert_eq!(decoded.activity_id, prelogin.activity_id); } + #[test] + fn decode_accepts_zero_length_threadid() { + // A THREADID option with length 0 must decode to thread_id 0, not error. + // Table entry (token, offset=6, length=0) then the terminator. + let mut buf = BytesMut::from( + &[ + PRELOGIN_THREADID, + 0x00, + 0x06, + 0x00, + 0x00, + PRELOGIN_TERMINATOR, + ][..], + ); + let decoded = PreloginMessage::decode(&mut buf).expect("zero-length threadid must decode"); + assert_eq!(decoded.thread_id, 0); + } + + #[test] + fn decode_reads_nonce_option() { + // Table entry for NONCEOPT (offset 6, length 32) + terminator + 32 bytes. + let mut bytes = vec![ + PRELOGIN_NONCEOPT, + 0x00, + 0x06, + 0x00, + 0x20, + PRELOGIN_TERMINATOR, + ]; + bytes.extend_from_slice(&[0xAB; 32]); + let mut buf = BytesMut::from(&bytes[..]); + + let decoded = PreloginMessage::decode(&mut buf).expect("nonce option must decode"); + assert_eq!(decoded.nonce, Some([0xAB; 32])); + } + + #[test] + fn option_payload_returns_none_for_absent_token() { + let mut payload = BytesMut::new(); + PreloginMessage::new() + .encode(&mut payload) + .expect("encode should succeed"); + + // A fresh message emits no TRACEID option. + assert_eq!(option_payload(&payload, PRELOGIN_TRACEID), None); + } + + #[test] + fn decode_rejects_invalid_encryption_value() { + // ENCRYPTION option (offset 6, length 1) + terminator + an out-of-range + // encryption byte. + let mut buf = BytesMut::from( + &[ + PRELOGIN_ENCRYPTION, + 0x00, + 0x06, + 0x00, + 0x01, + PRELOGIN_TERMINATOR, + 0x63, // 99: not a valid EncryptionLevel + ][..], + ); + + match PreloginMessage::decode(&mut buf) { + Err(Error::Protocol(_)) => {} + other => panic!("expected protocol error, got {other:?}"), + } + } + + #[test] + fn decode_rejects_invalid_threadid_length() { + // THREADID option with an unsupported length (2) must error. + let mut buf = BytesMut::from( + &[ + PRELOGIN_THREADID, + 0x00, + 0x06, + 0x00, + 0x02, + PRELOGIN_TERMINATOR, + 0x00, + 0x00, + ][..], + ); + + match PreloginMessage::decode(&mut buf) { + Err(Error::Protocol(_)) => {} + other => panic!("expected protocol error, got {other:?}"), + } + } + + #[test] + fn decode_rejects_unsupported_token() { + // An unknown option token must produce a protocol error. + let mut buf = BytesMut::from( + &[ + 0x50, // unsupported token + 0x00, + 0x06, + 0x00, + 0x01, + PRELOGIN_TERMINATOR, + 0x00, + ][..], + ); + + match PreloginMessage::decode(&mut buf) { + Err(Error::Protocol(_)) => {} + other => panic!("expected protocol error, got {other:?}"), + } + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[test] + fn negotiated_encryption_off_and_strict() { + let mut prelogin = PreloginMessage::new(); + + // Both sides Off -> Off. + prelogin.encryption = EncryptionLevel::Off; + assert_eq!( + prelogin + .negotiated_encryption(EncryptionLevel::Off) + .unwrap(), + EncryptionLevel::Off + ); + + // Strict is negotiated out-of-band; it stays Strict regardless of the + // server's advertised level. + assert_eq!( + prelogin + .negotiated_encryption(EncryptionLevel::Strict) + .unwrap(), + EncryptionLevel::Strict + ); + } + #[test] fn validate_instance_accepts_valid_response() { // Server valid response = lone 0x00 -> decoded as `None`. diff --git a/src/tds/codec/token.rs b/src/tds/codec/token.rs index 447792c6d..3c8ad9805 100644 --- a/src/tds/codec/token.rs +++ b/src/tds/codec/token.rs @@ -35,3 +35,21 @@ pub use token_session_state::*; pub use token_sspi::*; pub use token_tab_name::*; pub use token_type::*; + +/// Upper bound on the length a variable-length token declares for its body +/// before we allocate for it. The length is server-controlled; without a cap a +/// single 4-byte field could force a multi-gigabyte allocation. Chosen well +/// above any realistic FEDAUTHINFO / SESSIONSTATE payload. +pub(crate) const MAX_TOKEN_BODY: usize = 16 * 1024 * 1024; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn max_token_body_is_sixteen_mebibytes() { + // 16 MiB. Guards the `16 * 1024 * 1024` computation against arithmetic + // mutation (e.g. `+`/`/` would yield a wildly different cap). + assert_eq!(MAX_TOKEN_BODY, 16_777_216); + } +} diff --git a/src/tds/codec/token/token_alt_meta_data.rs b/src/tds/codec/token/token_alt_meta_data.rs index 9a9bd3310..5537b81d8 100644 --- a/src/tds/codec/token/token_alt_meta_data.rs +++ b/src/tds/codec/token/token_alt_meta_data.rs @@ -64,7 +64,13 @@ impl TokenAltMetaData<'static> { by_columns.push(src.read_u16_le().await?); } - let mut columns = Vec::with_capacity(column_count as usize); + // `column_count` is an untrusted u16 (up to 65535); cap the up-front + // reservation so a hostile ALTMETADATA token can't force a large + // transient allocation before the column data has arrived. The Vec + // still grows as real columns are decoded. + let mut columns = Vec::with_capacity( + (column_count as usize).min(crate::tds::codec::column_data::MAX_PREALLOC), + ); for _ in 0..column_count { let op = src.read_u8().await?; let operand = src.read_u16_le().await?; diff --git a/src/tds/codec/token/token_alt_row.rs b/src/tds/codec/token/token_alt_row.rs index c9dcbe015..284506ee5 100644 --- a/src/tds/codec/token/token_alt_row.rs +++ b/src/tds/codec/token/token_alt_row.rs @@ -113,4 +113,27 @@ mod tests { assert_eq!(1, row.len()); assert!(matches!(row.get(0), Some(ColumnData::I32(Some(42))))); } + + #[test] + fn accessors_reflect_id_and_columns() { + // Empty row: id must be the stored value (not a hardcoded 1), len 0, + // is_empty true. + let empty = TokenAltRow { + id: 5, + data: vec![], + }; + assert_eq!(empty.id(), 5); + assert_eq!(empty.len(), 0); + assert!(empty.is_empty()); + + // Non-empty row with a distinct id and two columns: len 2, is_empty + // false. + let filled = TokenAltRow { + id: 9, + data: vec![ColumnData::I32(Some(10)), ColumnData::I32(Some(20))], + }; + assert_eq!(filled.id(), 9); + assert_eq!(filled.len(), 2); + assert!(!filled.is_empty()); + } } diff --git a/src/tds/codec/token/token_col_info.rs b/src/tds/codec/token/token_col_info.rs index d74adbb76..07b3b7432 100644 --- a/src/tds/codec/token/token_col_info.rs +++ b/src/tds/codec/token/token_col_info.rs @@ -160,4 +160,79 @@ mod tests { assert!(!second.is_hidden()); assert_eq!(second.col_name.as_deref(), Some("Id")); } + + #[test] + fn colinfo_status_predicates() { + // Expression-only: is_expression true, the others false. + let expr = ColInfo { + col_num: 1, + table_num: 0, + status: STATUS_EXPRESSION, + col_name: None, + }; + assert!(expr.is_expression()); + assert!(!expr.is_key()); + assert!(!expr.is_hidden()); + + // Key-only: is_key true, the others false. (Status 0x08 shares no bits + // with STATUS_EXPRESSION 0x04, so `&`->`|`/`^` would wrongly report an + // expression here.) + let key = ColInfo { + col_num: 1, + table_num: 0, + status: STATUS_KEY, + col_name: None, + }; + assert!(!key.is_expression()); + assert!(key.is_key()); + assert!(!key.is_hidden()); + + // Hidden-only: is_hidden true, the others false. + let hidden = ColInfo { + col_num: 1, + table_num: 0, + status: STATUS_HIDDEN, + col_name: None, + }; + assert!(!hidden.is_expression()); + assert!(!hidden.is_key()); + assert!(hidden.is_hidden()); + } + + #[tokio::test] + async fn decode_col_info_advances_consumed_by_name_bytes() { + // A different-name column (with a multi-character name) followed by a + // plain column. The `consumed += char_len * 2` update must be exact for + // the loop to read *both* columns: `+=`->`*=` would overshoot and stop + // after the first column, `*`->`+` would undershoot and run off the end + // of the buffer. + let mut body = BytesMut::new(); + + // Column 1: expression + different name "abc" (3 chars => 6 bytes). + body.put_u8(1); // ColNum + body.put_u8(0); // TableNum + body.put_u8(STATUS_EXPRESSION | STATUS_DIFFERENT_NAME); // Status + body.put_u8(3); // ColName length in characters + body.put_u16_le(u16::from(b'a')); + body.put_u16_le(u16::from(b'b')); + body.put_u16_le(u16::from(b'c')); + + // Column 2: plain key column, no different name. + body.put_u8(2); // ColNum + body.put_u8(1); // TableNum + body.put_u8(STATUS_KEY); // Status + + let mut buf = BytesMut::new(); + buf.put_u16_le(body.len() as u16); + buf.extend_from_slice(&body); + + let mut reader = buf.into_sql_read_bytes(); + let token = TokenColInfo::decode(&mut reader).await.unwrap(); + + assert_eq!(token.columns.len(), 2); + assert_eq!(token.columns[0].col_num, 1); + assert_eq!(token.columns[0].col_name.as_deref(), Some("abc")); + assert_eq!(token.columns[1].col_num, 2); + assert!(token.columns[1].is_key()); + } } diff --git a/src/tds/codec/token/token_col_metadata.rs b/src/tds/codec/token/token_col_metadata.rs index c8c455472..f062fcec5 100644 --- a/src/tds/codec/token/token_col_metadata.rs +++ b/src/tds/codec/token/token_col_metadata.rs @@ -56,7 +56,10 @@ impl<'a> Display for MetaDataColumn<'a> { FixedLenType::Float8 => write!(f, "float")?, FixedLenType::Money4 => write!(f, "smallmoney")?, FixedLenType::Int8 => write!(f, "bigint")?, - FixedLenType::Null => unreachable!(), + // The TDS "null" fixed type carries no value; surface it as the + // int it decodes to rather than panicking (a bare `SELECT NULL` + // produces such a column). + FixedLenType::Null => write!(f, "int")?, }, TypeInfo::VarLenSized(ctx) => match ctx.r#type() { VarLenType::Bitn => write!(f, "bit")?, @@ -68,6 +71,10 @@ impl<'a> Display for MetaDataColumn<'a> { #[cfg(feature = "tds73")] VarLenType::Datetime2 => write!(f, "datetime2({})", ctx.len())?, VarLenType::Datetimen => write!(f, "datetime")?, + VarLenType::Money => match ctx.len() { + 4 => write!(f, "smallmoney")?, + _ => write!(f, "money")?, + }, #[cfg(feature = "tds73")] VarLenType::DatetimeOffsetn => write!(f, "datetimeoffset")?, VarLenType::BigVarBin => { @@ -101,16 +108,16 @@ impl<'a> Display for MetaDataColumn<'a> { 1 => write!(f, "tinyint")?, 2 => write!(f, "smallint")?, 4 => write!(f, "int")?, - 8 => write!(f, "bigint")?, - _ => unreachable!(), + _ => write!(f, "bigint")?, }, VarLenType::Floatn => match ctx.len() { 4 => write!(f, "real")?, - 8 => write!(f, "float")?, - _ => unreachable!(), + _ => write!(f, "float")?, }, VarLenType::SSVariant => write!(f, "sql_variant")?, - _ => unreachable!(), + // Any other var-len type: emit its debug name rather than + // panicking, so formatting metadata never crashes. + other => write!(f, "{other:?}")?, }, TypeInfo::VarLenSizedPrecision { ty, @@ -118,9 +125,9 @@ impl<'a> Display for MetaDataColumn<'a> { precision, scale, } => match ty { - VarLenType::Decimaln => write!(f, "decimal({},{})", precision, scale)?, VarLenType::Numericn => write!(f, "numeric({},{})", precision, scale)?, - _ => unreachable!(), + // Decimaln, and any other precision-carrying type. + _ => write!(f, "decimal({},{})", precision, scale)?, }, TypeInfo::Xml { .. } => write!(f, "xml")?, TypeInfo::Udt(info) => write!(f, "{}.{}", info.schema_name, info.type_name)?, @@ -218,7 +225,11 @@ impl BaseMetaDataColumn { VarLenType::NVarchar => ColumnData::String(None), VarLenType::NChar => ColumnData::String(None), VarLenType::Xml => ColumnData::Xml(None), - VarLenType::Udt => todo!("User-defined types not supported"), + // A null CLR UDT carries no payload; surface it as a null + // binary, matching `udt::decode` (which yields + // `ColumnData::Binary`). Previously this panicked via `todo!()`, + // which a bulk insert of a NULL UDT column could reach. + VarLenType::Udt => ColumnData::Binary(None), VarLenType::Text => ColumnData::String(None), VarLenType::Image => ColumnData::Binary(None), VarLenType::NText => ColumnData::String(None), @@ -250,7 +261,11 @@ impl BaseMetaDataColumn { VarLenType::NVarchar => ColumnData::String(None), VarLenType::NChar => ColumnData::String(None), VarLenType::Xml => ColumnData::Xml(None), - VarLenType::Udt => todo!("User-defined types not supported"), + // A null CLR UDT carries no payload; surface it as a null + // binary, matching `udt::decode` (which yields + // `ColumnData::Binary`). Previously this panicked via `todo!()`, + // which a bulk insert of a NULL UDT column could reach. + VarLenType::Udt => ColumnData::Binary(None), VarLenType::Text => ColumnData::String(None), VarLenType::Image => ColumnData::Binary(None), VarLenType::NText => ColumnData::String(None), @@ -314,7 +329,7 @@ impl Encode for BaseMetaDataColumn { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColumnFlag { /// The column can be null. - Nullable = 1 << 0, + Nullable = 1, /// Set for string columns with binary collation and always for the XML data /// type. CaseSensitive = 1 << 1, @@ -351,9 +366,17 @@ impl TokenColMetaData<'static> { R: SqlReadBytes + Unpin, { let column_count = src.read_u16_le().await?; - let mut columns = Vec::with_capacity(column_count as usize); + // `column_count` is an untrusted u16 (up to 65535); cap the up-front + // reservation so a hostile COLMETADATA token can't force a large + // transient allocation before the column bodies arrive. The Vec still + // grows as real columns are decoded. + let mut columns = Vec::with_capacity( + (column_count as usize).min(crate::tds::codec::column_data::MAX_PREALLOC), + ); - if column_count > 0 && column_count < 0xffff { + // `0xffff` is the "no metadata" sentinel; any other count drives the + // loop directly (a count of 0 simply iterates zero times). + if column_count < 0xffff { for _ in 0..column_count { let base = BaseMetaDataColumn::decode(src).await?; let col_name = Cow::from(src.read_b_varchar().await?); @@ -406,3 +429,635 @@ impl BaseMetaDataColumn { Ok(BaseMetaDataColumn { flags, ty }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use crate::tds::Collation; + use crate::VarLenContext; + + fn meta(ty: TypeInfo, name: &'static str) -> MetaDataColumn<'static> { + MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty, + }, + col_name: Cow::Borrowed(name), + } + } + + #[tokio::test] + async fn round_trip_via_encode_decode() { + let cmd = TokenColMetaData { + columns: vec![ + meta(TypeInfo::FixedLen(FixedLenType::Int4), "id"), + meta( + TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::NVarchar, + 4000, + Some(Collation::new(13632521, 52)), + )), + "name", + ), + ], + }; + + // Build a decodable buffer: column count followed by each column. The + // MetaDataColumn encoder writes the leading user-type u32 that the + // decoder expects. + let mut buf = BytesMut::new(); + buf.put_u16_le(cmd.columns.len() as u16); + for col in cmd.columns.iter().cloned() { + col.encode(&mut buf).unwrap(); + } + + let decoded = TokenColMetaData::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(decoded.columns.len(), 2); + assert_eq!(decoded.columns[0].col_name, "id"); + assert_eq!(decoded.columns[1].col_name, "name"); + + let columns: Vec<_> = decoded.columns().collect(); + assert_eq!(columns.len(), 2); + assert_eq!(columns[0].name(), "id"); + } + + #[test] + fn encode_writes_token_header_and_column_count() { + let cmd = TokenColMetaData { + columns: vec![ + meta(TypeInfo::FixedLen(FixedLenType::Int4), "id"), + meta(TypeInfo::FixedLen(FixedLenType::Bit), "flag"), + ], + }; + + let mut buf = BytesMut::new(); + cmd.encode(&mut buf).unwrap(); + + // First the ColMetaData token byte, then the little-endian column count. + assert_eq!(buf[0], TokenType::ColMetaData as u8); + assert_eq!(u16::from_le_bytes([buf[1], buf[2]]), 2); + // The two column bodies follow the 3-byte header. + assert!(buf.len() > 3); + } + + #[tokio::test] + async fn zero_columns_yields_empty() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); + + let decoded = TokenColMetaData::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert!(decoded.columns.is_empty()); + } + + #[tokio::test] + async fn text_column_reads_table_name_parts() { + let mut buf = BytesMut::new(); + buf.put_u16_le(1); // one column + + // user_ty + flags + buf.put_u32_le(0); + buf.put_u16_le(BitFlags::bits(BitFlags::from(ColumnFlag::Nullable))); + + // type info for a text column with collation + let ti = TypeInfo::VarLenSized(VarLenContext::new( + VarLenType::Text, + 2147483647, + Some(Collation::new(13632521, 52)), + )); + ti.encode(&mut buf).unwrap(); + + // table name: one part, us_varchar "dbo" + buf.put_u8(1); + let part: Vec = "dbo".encode_utf16().collect(); + buf.put_u16_le(part.len() as u16); + for c in part { + buf.put_u16_le(c); + } + + // column name (b_varchar) + let name: Vec = "body".encode_utf16().collect(); + buf.put_u8(name.len() as u8); + for c in name { + buf.put_u16_le(c); + } + + let decoded = TokenColMetaData::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(decoded.columns.len(), 1); + assert_eq!(decoded.columns[0].col_name, "body"); + } + + #[test] + fn display_formats_various_types() { + let cases = vec![ + (TypeInfo::FixedLen(FixedLenType::Int4), "c int"), + (TypeInfo::FixedLen(FixedLenType::Bit), "c bit"), + (TypeInfo::FixedLen(FixedLenType::Float8), "c float"), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 1, None)), + "c tinyint", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 4, None)), + "c int", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Floatn, 4, None)), + "c real", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Guid, 16, None)), + "c uniqueidentifier", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigVarBin, 100, None)), + "c varbinary(100)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigVarBin, 100000, None)), + "c varbinary(max)", + ), + ( + TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Decimaln, + size: 17, + precision: 18, + scale: 2, + }, + "c decimal(18,2)", + ), + // Numericn must render as `numeric(...)`, distinct from the + // `decimal(...)` fallback that every other precision type uses. + ( + TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Numericn, + size: 9, + precision: 10, + scale: 4, + }, + "c numeric(10,4)", + ), + ( + TypeInfo::Xml { + schema: None, + size: 0, + }, + "c xml", + ), + ]; + + for (ty, expected) in cases { + // Display brackets the column name for use in bulk `INSERT` statements. + let expected = expected.replacen("c ", "[c] ", 1); + assert_eq!(format!("{}", meta(ty, "c")), expected); + } + } + + #[test] + fn null_value_maps_types() { + let fixed = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }; + assert_eq!(fixed.null_value(), ColumnData::I32(None)); + + let varlen = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 2, None)), + }; + assert_eq!(varlen.null_value(), ColumnData::I16(None)); + + // Each Intn width maps to a distinct integer column; 1 and 4 sit either + // side of the `_ => I64` fallback and pin their own arms. + let tinyint = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 1, None)), + }; + assert_eq!(tinyint.null_value(), ColumnData::U8(None)); + + let int4 = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 4, None)), + }; + assert_eq!(int4.null_value(), ColumnData::I32(None)); + + let int8 = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 8, None)), + }; + assert_eq!(int8.null_value(), ColumnData::I64(None)); + + // Floatn splits on width too: 4 bytes is F32, anything else F64. + let real = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Floatn, 4, None)), + }; + assert_eq!(real.null_value(), ColumnData::F32(None)); + + let double = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Floatn, 8, None)), + }; + assert_eq!(double.null_value(), ColumnData::F64(None)); + + let guid = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Guid, 16, None)), + }; + assert_eq!(guid.null_value(), ColumnData::Guid(None)); + } + + #[test] + fn null_value_maps_precision_and_xml_and_udt() { + let precision = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Numericn, + size: 17, + precision: 18, + scale: 2, + }, + }; + assert_eq!(precision.null_value(), ColumnData::Numeric(None)); + + let xml = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::Xml { + schema: None, + size: 0, + }, + }; + assert_eq!(xml.null_value(), ColumnData::Xml(None)); + + let udt = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::Udt(crate::tds::codec::type_info::UdtInfo { + max_byte_size: 0, + db_name: "db".into(), + schema_name: "dbo".into(), + type_name: "T".into(), + assembly_qualified_name: "A".into(), + }), + }; + assert_eq!(udt.null_value(), ColumnData::Binary(None)); + } + + #[test] + fn base_meta_data_column_flag_accessors() { + let base = BaseMetaDataColumn { + flags: ColumnFlag::Nullable | ColumnFlag::Identity | ColumnFlag::Updateable, + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }; + + assert!(base.is_nullable()); + assert!(base.is_identity()); + assert!(base.is_updateable()); + assert_eq!(base.ty(), &TypeInfo::FixedLen(FixedLenType::Int4)); + assert_eq!(base.flags(), base.flags); + + let base2 = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }; + assert!(!base2.is_nullable()); + assert!(!base2.is_identity()); + assert!(!base2.is_updateable()); + } + + #[test] + fn meta_data_column_accessors() { + let m = meta(TypeInfo::FixedLen(FixedLenType::Int4), "id"); + assert_eq!(m.col_name(), "id"); + assert!(m.base().is_nullable()); + } + + #[test] + fn display_formats_more_types() { + let cases = vec![ + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Bitn, 1, None)), + "c bit", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Datetimen, 8, None)), + "c datetime", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 4, None)), + "c smallmoney", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Money, 8, None)), + "c money", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigVarChar, 100, None)), + "c varchar(100)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigVarChar, 100000, None)), + "c varchar(max)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigBinary, 10, None)), + "c binary(10)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::BigChar, 10, None)), + "c char(10)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NVarchar, 100, None)), + "c nvarchar(100)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NVarchar, 100000, None)), + "c nvarchar(max)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NChar, 10, None)), + "c nchar(10)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Text, 0, None)), + "c text", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Image, 0, None)), + "c image", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::NText, 0, None)), + "c ntext", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 2, None)), + "c smallint", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 8, None)), + "c bigint", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Floatn, 8, None)), + "c float", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::SSVariant, 0, None)), + "c sql_variant", + ), + ]; + + for (ty, expected) in cases { + let expected = expected.replacen("c ", "[c] ", 1); + assert_eq!(format!("{}", meta(ty, "c")), expected); + } + } + + #[test] + fn display_formats_udt_and_decimaln() { + let udt = TypeInfo::Udt(crate::tds::codec::type_info::UdtInfo { + max_byte_size: 0, + db_name: "db".into(), + schema_name: "dbo".into(), + type_name: "MyType".into(), + assembly_qualified_name: "asm".into(), + }); + assert_eq!(format!("{}", meta(udt, "c")), "[c] dbo.MyType"); + + let decimaln = TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Decimaln, + size: 17, + precision: 10, + scale: 4, + }; + assert_eq!(format!("{}", meta(decimaln, "c")), "[c] decimal(10,4)"); + } + + #[tokio::test] + async fn decode_all_ones_column_count_yields_empty() { + // column_count == 0xffff is treated as "no columns" (guards against a + // sentinel/placeholder value rather than a real column list). + let mut buf = BytesMut::new(); + buf.put_u16_le(0xffff); + + let decoded = TokenColMetaData::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert!(decoded.columns.is_empty()); + } + + #[test] + fn display_formats_fixed_len_money_and_datetime_types() { + // Covers the FixedLenType Display arms not exercised elsewhere: + // tinyint/smallint/smalldatetime/real/money/datetime/smallmoney/bigint + // and the `Null` sentinel (which surfaces as `int`). + let cases = vec![ + (TypeInfo::FixedLen(FixedLenType::Int1), "c tinyint"), + (TypeInfo::FixedLen(FixedLenType::Int2), "c smallint"), + ( + TypeInfo::FixedLen(FixedLenType::Datetime4), + "c smalldatetime", + ), + (TypeInfo::FixedLen(FixedLenType::Float4), "c real"), + (TypeInfo::FixedLen(FixedLenType::Money), "c money"), + (TypeInfo::FixedLen(FixedLenType::Datetime), "c datetime"), + (TypeInfo::FixedLen(FixedLenType::Money4), "c smallmoney"), + (TypeInfo::FixedLen(FixedLenType::Int8), "c bigint"), + (TypeInfo::FixedLen(FixedLenType::Null), "c int"), + ]; + + for (ty, expected) in cases { + let expected = expected.replacen("c ", "[c] ", 1); + assert_eq!(format!("{}", meta(ty, "c")), expected); + } + } + + #[cfg(feature = "tds73")] + #[test] + fn display_formats_tds73_date_time_types() { + // date/time/datetime2/datetimeoffset Display arms (tds73-only). + let cases = vec![ + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Daten, 3, None)), + "c date", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Timen, 7, None)), + "c time", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Datetime2, 7, None)), + "c datetime2(7)", + ), + ( + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::DatetimeOffsetn, 7, None)), + "c datetimeoffset", + ), + ]; + + for (ty, expected) in cases { + let expected = expected.replacen("c ", "[c] ", 1); + assert_eq!(format!("{}", meta(ty, "c")), expected); + } + } + + #[test] + fn display_var_len_other_fallback_uses_debug_name() { + // A VarLenSized carrying a type not matched by any explicit Display arm + // (e.g. Decimaln) hits the `other => {other:?}` fallback rather than + // panicking. + let ty = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Decimaln, 17, None)); + assert_eq!(format!("{}", meta(ty, "c")), "[c] Decimaln"); + } + + #[test] + fn null_value_all_fixed_len() { + use FixedLenType::*; + let cases = [ + (Null, ColumnData::I32(None)), + (Int1, ColumnData::U8(None)), + (Bit, ColumnData::Bit(None)), + (Int2, ColumnData::I16(None)), + (Int4, ColumnData::I32(None)), + (Datetime4, ColumnData::SmallDateTime(None)), + (Float4, ColumnData::F32(None)), + (Money, ColumnData::F64(None)), + (Datetime, ColumnData::DateTime(None)), + (Float8, ColumnData::F64(None)), + (Money4, ColumnData::F32(None)), + (Int8, ColumnData::I64(None)), + ]; + + for (ty, expected) in cases { + let base = BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::FixedLen(ty), + }; + assert_eq!(base.null_value(), expected); + } + } + + fn vsize_null(ty: VarLenType, len: usize) -> ColumnData<'static> { + BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSized(VarLenContext::new(ty, len, None)), + } + .null_value() + } + + #[test] + fn null_value_all_var_len_sized() { + use VarLenType::*; + assert_eq!(vsize_null(Guid, 16), ColumnData::Guid(None)); + assert_eq!(vsize_null(Bitn, 1), ColumnData::Bit(None)); + assert_eq!(vsize_null(Decimaln, 17), ColumnData::Numeric(None)); + assert_eq!(vsize_null(Numericn, 17), ColumnData::Numeric(None)); + assert_eq!(vsize_null(Money, 8), ColumnData::F64(None)); + assert_eq!(vsize_null(Datetimen, 8), ColumnData::DateTime(None)); + assert_eq!(vsize_null(BigVarBin, 100), ColumnData::Binary(None)); + assert_eq!(vsize_null(BigVarChar, 100), ColumnData::String(None)); + assert_eq!(vsize_null(BigBinary, 10), ColumnData::Binary(None)); + assert_eq!(vsize_null(BigChar, 10), ColumnData::String(None)); + assert_eq!(vsize_null(NVarchar, 100), ColumnData::String(None)); + assert_eq!(vsize_null(NChar, 10), ColumnData::String(None)); + assert_eq!(vsize_null(Xml, 0), ColumnData::Xml(None)); + assert_eq!(vsize_null(Udt, 0), ColumnData::Binary(None)); + assert_eq!(vsize_null(Text, 0), ColumnData::String(None)); + assert_eq!(vsize_null(Image, 0), ColumnData::Binary(None)); + assert_eq!(vsize_null(NText, 0), ColumnData::String(None)); + assert_eq!(vsize_null(SSVariant, 0), ColumnData::String(None)); + } + + #[cfg(feature = "tds73")] + #[test] + fn null_value_var_len_sized_tds73() { + use VarLenType::*; + assert_eq!(vsize_null(Daten, 3), ColumnData::Date(None)); + assert_eq!(vsize_null(Timen, 7), ColumnData::Time(None)); + assert_eq!(vsize_null(Datetime2, 7), ColumnData::DateTime2(None)); + assert_eq!( + vsize_null(DatetimeOffsetn, 7), + ColumnData::DateTimeOffset(None) + ); + } + + fn vprec_null(ty: VarLenType) -> ColumnData<'static> { + BaseMetaDataColumn { + flags: BitFlags::empty(), + ty: TypeInfo::VarLenSizedPrecision { + ty, + size: 8, + precision: 18, + scale: 2, + }, + } + .null_value() + } + + #[test] + fn null_value_all_var_len_precision() { + use VarLenType::*; + assert_eq!(vprec_null(Guid), ColumnData::Guid(None)); + assert_eq!(vprec_null(Intn), ColumnData::I32(None)); + assert_eq!(vprec_null(Bitn), ColumnData::Bit(None)); + assert_eq!(vprec_null(Decimaln), ColumnData::Numeric(None)); + assert_eq!(vprec_null(Numericn), ColumnData::Numeric(None)); + assert_eq!(vprec_null(Floatn), ColumnData::F32(None)); + assert_eq!(vprec_null(Money), ColumnData::F64(None)); + assert_eq!(vprec_null(Datetimen), ColumnData::DateTime(None)); + assert_eq!(vprec_null(BigVarBin), ColumnData::Binary(None)); + assert_eq!(vprec_null(BigVarChar), ColumnData::String(None)); + assert_eq!(vprec_null(BigBinary), ColumnData::Binary(None)); + assert_eq!(vprec_null(BigChar), ColumnData::String(None)); + assert_eq!(vprec_null(NVarchar), ColumnData::String(None)); + assert_eq!(vprec_null(NChar), ColumnData::String(None)); + assert_eq!(vprec_null(Xml), ColumnData::Xml(None)); + assert_eq!(vprec_null(Udt), ColumnData::Binary(None)); + assert_eq!(vprec_null(Text), ColumnData::String(None)); + assert_eq!(vprec_null(Image), ColumnData::Binary(None)); + assert_eq!(vprec_null(NText), ColumnData::String(None)); + assert_eq!(vprec_null(SSVariant), ColumnData::String(None)); + } + + #[cfg(feature = "tds73")] + #[test] + fn null_value_var_len_precision_tds73() { + use VarLenType::*; + assert_eq!(vprec_null(Daten), ColumnData::Date(None)); + assert_eq!(vprec_null(Timen), ColumnData::Time(None)); + assert_eq!(vprec_null(Datetime2), ColumnData::DateTime2(None)); + assert_eq!( + vprec_null(DatetimeOffsetn), + ColumnData::DateTimeOffset(None) + ); + } + + #[test] + fn column_flag_bits_are_distinct() { + let all = ColumnFlag::Nullable + | ColumnFlag::CaseSensitive + | ColumnFlag::Updateable + | ColumnFlag::UpdateableUnknown + | ColumnFlag::Identity + | ColumnFlag::Computed + | ColumnFlag::FixedLenClrType + | ColumnFlag::SparseColumnSet + | ColumnFlag::Encrypted + | ColumnFlag::Hidden + | ColumnFlag::Key + | ColumnFlag::NullableUnknown; + + assert!(all.contains(ColumnFlag::Nullable)); + assert!(all.contains(ColumnFlag::NullableUnknown)); + assert_eq!(BitFlags::bits(all).count_ones(), 12); + } +} diff --git a/src/tds/codec/token/token_done.rs b/src/tds/codec/token/token_done.rs index 0285d9879..d655210c3 100644 --- a/src/tds/codec/token/token_done.rs +++ b/src/tds/codec/token/token_done.rs @@ -1,4 +1,4 @@ -use crate::{tds::codec::Encode, Error, SqlReadBytes, TokenType}; +use crate::{tds::codec::Encode, SqlReadBytes, TokenType}; use asynchronous_codec::BytesMut; use bytes::BufMut; use enumflags2::{bitflags, BitFlags}; @@ -15,7 +15,7 @@ pub struct TokenDone { #[repr(u16)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DoneStatus { - More = 1 << 0, + More = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) Error = 1 << 1, Inexact = 1 << 2, // reserved @@ -31,8 +31,11 @@ impl TokenDone { where R: SqlReadBytes + Unpin, { - let status = BitFlags::from_bits(src.read_u16_le().await?) - .map_err(|_| Error::Protocol("done(variant): invalid status".into()))?; + // The DONE Status (MS-TDS §2.2.7.6) is a 2-byte bitmask with reserved + // bits that a server (or a future SQL Server / Azure build) may set. + // Truncate to the flags we model rather than erroring, matching how + // COLMETADATA flags are handled. + let status = BitFlags::from_bits_truncate(src.read_u16_le().await?); let cur_cmd = src.read_u16_le().await?; let done_row_count_bytes = src.context().version().done_row_count_bytes(); @@ -62,7 +65,13 @@ impl TokenDone { } pub(crate) fn rows(&self) -> u64 { - self.done_rows + // The row count is only meaningful when the DONE_COUNT status bit is + // set (MS-TDS §2.2.7.6); otherwise the field is not a valid count. + if self.status.contains(DoneStatus::Count) { + self.done_rows + } else { + 0 + } } } @@ -93,3 +102,130 @@ impl fmt::Display for TokenDone { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::BytesMut; + + #[tokio::test] + async fn decode_final_done() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); // status: empty => final + buf.put_u16_le(0); // cur_cmd + buf.put_u64_le(0); // done_rows (SqlServerN => 8 bytes) + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert!(done.is_final()); + assert_eq!(done.rows(), 0); + assert!(format!("{}", done).starts_with("Done with status")); + } + + #[tokio::test] + async fn decode_with_count_and_rows() { + let mut buf = BytesMut::new(); + buf.put_u16_le(DoneStatus::Count as u16); + buf.put_u16_le(0); + buf.put_u64_le(5); + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert!(!done.is_final()); + assert_eq!(done.rows(), 5); + assert!(format!("{}", done).contains("5 rows left")); + } + + #[tokio::test] + async fn decode_reads_four_byte_rowcount_on_pre_2005_versions() { + // Pre-2005 servers encode the DONE rowcount in 4 bytes; the decoder must + // pick the 4-byte arm from the negotiated version (kills "delete arm 4"). + use crate::sql_read_bytes::SqlReadBytes; + use crate::tds::codec::login::FeatureLevel; + + let mut buf = BytesMut::new(); + buf.put_u16_le(DoneStatus::Count as u16); + buf.put_u16_le(0); + buf.put_u32_le(7); // 4-byte rowcount + + let mut reader = buf.into_sql_read_bytes(); + reader + .context_mut() + .set_version(FeatureLevel::SqlServer2000); + + let done = TokenDone::decode(&mut reader).await.unwrap(); + assert_eq!(done.rows(), 7); + } + + #[tokio::test] + async fn decode_single_row_display() { + let mut buf = BytesMut::new(); + buf.put_u16_le(DoneStatus::Count as u16); + buf.put_u16_le(0); + buf.put_u64_le(1); + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert!(format!("{}", done).contains("1 row left")); + } + + #[tokio::test] + async fn decode_tolerates_reserved_status_bits() { + let mut buf = BytesMut::new(); + // bit 3 (0b1000 = 8) is reserved/undefined; combined with a real bit + // (More = 0b1). We tolerate the reserved bit and keep the modeled one. + buf.put_u16_le(0b1001); + buf.put_u16_le(0); + buf.put_u64_le(0); + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("reserved status bits must be tolerated"); + + assert!(done.status.contains(DoneStatus::More)); + } + + #[tokio::test] + async fn is_attention_reflects_attention_status_bit() { + // With the Attention bit set, is_attention() must be true. + let mut buf = BytesMut::new(); + buf.put_u16_le(DoneStatus::Attention as u16); + buf.put_u16_le(0); + buf.put_u64_le(0); + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert!(done.is_attention()); + + // Without the Attention bit (a different, non-attention bit set), + // is_attention() must be false. + let mut buf = BytesMut::new(); + buf.put_u16_le(DoneStatus::More as u16); + buf.put_u16_le(0); + buf.put_u64_le(0); + + let done = TokenDone::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert!(!done.is_attention()); + } + + #[test] + fn encode_writes_token_type_and_fields() { + let done = TokenDone::default(); + let mut buf = BytesMut::new(); + done.encode(&mut buf).unwrap(); + + assert_eq!(buf[0], TokenType::Done as u8); + // status(2) + cur_cmd(2) + done_rows(8) after the 1-byte token type + assert_eq!(buf.len(), 1 + 2 + 2 + 8); + } +} diff --git a/src/tds/codec/token/token_env_change.rs b/src/tds/codec/token/token_env_change.rs index 96d52d5a4..cbec7bb27 100644 --- a/src/tds/codec/token/token_env_change.rs +++ b/src/tds/codec/token/token_env_change.rs @@ -213,7 +213,11 @@ impl TokenEnvChange { } EnvChangeTy::BeginTransaction | EnvChangeTy::EnlistDTCTransaction => { let len = buf.read_u8()?; - assert!(len == 8); + if len != 8 { + return Err(Error::Protocol( + format!("ENVCHANGE transaction descriptor length {len}, expected 8").into(), + )); + } let mut desc = [0; 8]; buf.read_exact(&mut desc)?; @@ -263,7 +267,10 @@ impl TokenEnvChange { #[cfg(test)] mod tests { - use super::TokenEnvChange; + use super::{EnvChangeTy, TokenEnvChange}; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use byteorder::{LittleEndian, WriteBytesExt}; + use bytes::{BufMut, BytesMut}; #[test] fn database_display_uses_old_then_new() { @@ -284,4 +291,326 @@ mod tests { "Packet size change from '4096' to '8192'" ); } + + fn write_utf16_str(body: &mut Vec, s: &str) { + body.push(s.encode_utf16().count() as u8); + for unit in s.encode_utf16() { + body.write_u16::(unit).unwrap(); + } + } + + fn envchange_buf(ty: u8, payload: &[u8]) -> BytesMut { + let mut body = Vec::new(); + body.push(ty); + body.extend_from_slice(payload); + + let mut buf = BytesMut::new(); + buf.put_u16_le(body.len() as u16); + buf.put_slice(&body); + buf + } + + #[tokio::test] + async fn decode_database_roundtrip() { + let mut payload = Vec::new(); + write_utf16_str(&mut payload, "newdb"); + write_utf16_str(&mut payload, "olddb"); + + let buf = envchange_buf(EnvChangeTy::Database as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::Database(new, old) => { + assert_eq!(new, "newdb"); + assert_eq!(old, "olddb"); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_packet_size_parses_numbers() { + let mut payload = Vec::new(); + write_utf16_str(&mut payload, "8192"); + write_utf16_str(&mut payload, "4096"); + + let buf = envchange_buf(EnvChangeTy::PacketSize as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::PacketSize(new, old) => { + assert_eq!(new, 8192); + assert_eq!(old, 4096); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_sql_collation_with_both_present() { + let mut payload = Vec::new(); + payload.push(5u8); + payload.write_u32::(13632521).unwrap(); + payload.push(52); + payload.push(5u8); + payload.write_u32::(13632521).unwrap(); + payload.push(52); + + let buf = envchange_buf(EnvChangeTy::SqlCollation as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::SqlCollation { old, new } => { + assert!(old.is_some()); + assert!(new.is_some()); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_sql_collation_none_when_length_not_five() { + let payload = vec![0u8, 0u8]; + + let buf = envchange_buf(EnvChangeTy::SqlCollation as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::SqlCollation { old, new } => { + assert!(old.is_none()); + assert!(new.is_none()); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_begin_transaction_reads_descriptor() { + let mut payload = vec![8u8]; + payload.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + + let buf = envchange_buf(EnvChangeTy::BeginTransaction as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::BeginTransaction(desc) => { + assert_eq!(desc, [1, 2, 3, 4, 5, 6, 7, 8]); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_begin_transaction_wrong_length_errors() { + let payload = vec![3u8, 1, 2, 3]; + + let buf = envchange_buf(EnvChangeTy::BeginTransaction as u8, &payload); + let err = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap_err(); + + assert!(format!("{}", err).contains("expected 8")); + } + + #[tokio::test] + async fn decode_commit_rollback_defect_transaction() { + for (ty, is_match) in [ + ( + EnvChangeTy::CommitTransaction, + (|t: &TokenEnvChange| matches!(t, TokenEnvChange::CommitTransaction)) + as fn(&TokenEnvChange) -> bool, + ), + (EnvChangeTy::RollbackTransaction, |t: &TokenEnvChange| { + matches!(t, TokenEnvChange::RollbackTransaction) + }), + (EnvChangeTy::DefectTransaction, |t: &TokenEnvChange| { + matches!(t, TokenEnvChange::DefectTransaction) + }), + ] { + let buf = envchange_buf(ty as u8, &[]); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert!(is_match(&decoded)); + } + } + + #[tokio::test] + async fn decode_routing_reads_host_and_port() { + let mut payload = Vec::new(); + payload.write_u16::(0).unwrap(); // routing data value length (unused) + payload.push(0); // protocol, always 0 + payload.write_u16::(1433).unwrap(); // port + + let host = "sql.example.com"; + payload + .write_u16::(host.encode_utf16().count() as u16) + .unwrap(); + for unit in host.encode_utf16() { + payload.write_u16::(unit).unwrap(); + } + + let buf = envchange_buf(EnvChangeTy::Routing as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::Routing { host: h, port } => { + assert_eq!(h, host); + assert_eq!(port, 1433); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_rtls_yields_change_mirror() { + let mut payload = Vec::new(); + write_utf16_str(&mut payload, "mirror.example.com"); + + let buf = envchange_buf(EnvChangeTy::Rtls as u8, &payload); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::ChangeMirror(name) => { + assert_eq!(name, "mirror.example.com"); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_unhandled_type_is_ignored() { + let buf = envchange_buf(EnvChangeTy::Language as u8, &[]); + let decoded = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match decoded { + TokenEnvChange::Ignored(EnvChangeTy::Language) => {} + other => panic!("unexpected variant: {:?}", other), + } + } + + #[tokio::test] + async fn decode_invalid_type_byte_errors() { + let buf = envchange_buf(0x63, &[]); + let err = TokenEnvChange::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap_err(); + + assert!(format!("{}", err).contains("invalid envchange type")); + } + + #[test] + fn env_change_ty_display_all_variants() { + let cases: &[(EnvChangeTy, &str)] = &[ + (EnvChangeTy::Database, "Database"), + (EnvChangeTy::Language, "Language"), + (EnvChangeTy::CharacterSet, "CharacterSet"), + (EnvChangeTy::PacketSize, "PacketSize"), + (EnvChangeTy::UnicodeDataSortingLID, "UnicodeDataSortingLID"), + (EnvChangeTy::UnicodeDataSortingCFL, "UnicodeDataSortingCFL"), + (EnvChangeTy::SqlCollation, "SqlCollation"), + (EnvChangeTy::BeginTransaction, "BeginTransaction"), + (EnvChangeTy::CommitTransaction, "CommitTransaction"), + (EnvChangeTy::RollbackTransaction, "RollbackTransaction"), + (EnvChangeTy::EnlistDTCTransaction, "EnlistDTCTransaction"), + (EnvChangeTy::DefectTransaction, "DefectTransaction"), + (EnvChangeTy::Rtls, "RTLS"), + (EnvChangeTy::PromoteTransaction, "PromoteTransaction"), + ( + EnvChangeTy::TransactionManagerAddress, + "TransactionManagerAddress", + ), + (EnvChangeTy::TransactionEnded, "TransactionEnded"), + (EnvChangeTy::ResetConnection, "ResetConnection"), + (EnvChangeTy::UserName, "UserName"), + (EnvChangeTy::Routing, "Routing"), + ]; + + for (variant, expected) in cases { + assert_eq!(format!("{}", variant), *expected); + } + } + + #[test] + fn sql_collation_display_both_and_new_only() { + use crate::tds::Collation; + + // Both old and new present: "from {old} to {new}". + let both = TokenEnvChange::SqlCollation { + old: Some(Collation::new(13632521, 52)), + new: Some(Collation::new(13632521, 52)), + }; + assert!(format!("{}", both).starts_with("SQL collation change from ")); + + // Only new present: "changed to {new}". + let new_only = TokenEnvChange::SqlCollation { + old: None, + new: Some(Collation::new(13632521, 52)), + }; + assert!(format!("{}", new_only).starts_with("SQL collation changed to ")); + } + + #[test] + fn token_env_change_display_variants() { + assert_eq!( + format!("{}", TokenEnvChange::CommitTransaction), + "Commit transaction" + ); + assert_eq!( + format!("{}", TokenEnvChange::RollbackTransaction), + "Rollback transaction" + ); + assert_eq!( + format!("{}", TokenEnvChange::DefectTransaction), + "Defect transaction" + ); + assert_eq!( + format!("{}", TokenEnvChange::BeginTransaction([0; 8])), + "Begin transaction" + ); + assert_eq!( + format!( + "{}", + TokenEnvChange::Routing { + host: "host".into(), + port: 1433 + } + ), + "Server requested routing to a new address: host:1433" + ); + assert_eq!( + format!("{}", TokenEnvChange::ChangeMirror("mirror".into())), + "Fallback mirror server: `mirror`" + ); + assert_eq!( + format!("{}", TokenEnvChange::Ignored(EnvChangeTy::Language)), + "Ignored env change: `Language`" + ); + assert_eq!( + format!( + "{}", + TokenEnvChange::SqlCollation { + old: None, + new: None + } + ), + "SQL collation change" + ); + } } diff --git a/src/tds/codec/token/token_error.rs b/src/tds/codec/token/token_error.rs index d1e435a77..3727eb4f5 100644 --- a/src/tds/codec/token/token_error.rs +++ b/src/tds/codec/token/token_error.rs @@ -32,7 +32,11 @@ impl TokenError { let server = src.read_b_varchar().await?; let procedure = src.read_b_varchar().await?; - let line = if src.context().version() > FeatureLevel::SqlServer2005 { + // MS-TDS 2.2.7.10: LineNumber is a 4-byte LONG for TDS 7.2 (SQL Server + // 2005) and later, and a 2-byte USHORT before that. The boundary is + // inclusive of 7.2, so use `>=` — a strict `>` mis-reads a 2-byte value + // against a real SQL Server 2005 and desyncs the token stream. + let line = if src.context().version() >= FeatureLevel::SqlServer2005 { src.read_u32_le().await? } else { src.read_u16_le().await? as u32 @@ -101,3 +105,112 @@ impl fmt::Display for TokenError { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> TokenError { + TokenError { + code: 1205, + state: 2, + class: 13, + message: "deadlocked".to_string(), + server: "myserver".to_string(), + procedure: "myproc".to_string(), + line: 42, + } + } + + #[test] + fn accessors() { + let e = sample(); + assert_eq!(e.code(), 1205); + assert_eq!(e.state(), 2); + assert_eq!(e.class(), 13); + assert_eq!(e.message(), "deadlocked"); + assert_eq!(e.server(), "myserver"); + assert_eq!(e.procedure(), "myproc"); + assert_eq!(e.line(), 42); + } + + #[test] + fn display_contains_all_fields() { + let rendered = format!("{}", sample()); + assert_eq!( + rendered, + "'deadlocked' on server myserver executing myproc on line 42 (code: 1205, state: 2, class: 13)" + ); + } + + #[tokio::test] + async fn decode_reads_all_fields_with_four_byte_line_number() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use byteorder::{LittleEndian, WriteBytesExt}; + use bytes::{BufMut, BytesMut}; + + fn write_us_varchar(buf: &mut Vec, s: &str) { + buf.write_u16::(s.encode_utf16().count() as u16) + .unwrap(); + for u in s.encode_utf16() { + buf.write_u16::(u).unwrap(); + } + } + + fn write_b_varchar(buf: &mut Vec, s: &str) { + buf.push(s.encode_utf16().count() as u8); + for u in s.encode_utf16() { + buf.write_u16::(u).unwrap(); + } + } + + let mut body = Vec::new(); + body.write_u32::(1205).unwrap(); // code + body.push(2); // state + body.push(13); // class + write_us_varchar(&mut body, "deadlocked"); + write_b_varchar(&mut body, "myserver"); + write_b_varchar(&mut body, "myproc"); + body.write_u32::(42).unwrap(); // line, TDS >= 7.2 (default context) + + let mut buf = BytesMut::new(); + buf.put_u16_le(body.len() as u16); // length prefix, ignored by decode + buf.put_slice(&body); + + let decoded = TokenError::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(decoded, sample()); + } + + #[tokio::test] + async fn decode_reads_full_four_byte_line_number_on_tds72_plus() { + // The default test context reports SqlServerN (>= TDS 7.2), so the + // LineNumber must be read as a 4-byte LONG. A `>` mutation of the + // `>=` boundary check would read only 2 bytes and mis-decode the value. + // 0x0001_0001 (65537) has distinct low-16-bit and full-32-bit values, so + // a 2-byte read yields 1 while the correct 4-byte read yields 65537. + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + let mut body = BytesMut::new(); + body.put_u32_le(1205); // code + body.put_u8(2); // state + body.put_u8(13); // class + body.put_u16_le(0); // message: us_varchar, length 0 + body.put_u8(0); // server: b_varchar, length 0 + body.put_u8(0); // procedure: b_varchar, length 0 + body.put_u32_le(0x0001_0001); // line number, 4 bytes + + let mut buf = BytesMut::new(); + buf.put_u16_le(body.len() as u16); // length prefix, ignored by decode + buf.put_slice(&body); + + let decoded = TokenError::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(decoded.line(), 0x0001_0001); + } +} diff --git a/src/tds/codec/token/token_feature_ext_ack.rs b/src/tds/codec/token/token_feature_ext_ack.rs index 74cb3564b..9d7fe7457 100644 --- a/src/tds/codec/token/token_feature_ext_ack.rs +++ b/src/tds/codec/token/token_feature_ext_ack.rs @@ -60,3 +60,86 @@ impl TokenFeatureExtAck { Ok(TokenFeatureExtAck { features }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn decodes_fedauth_with_nonce() { + let mut buf = BytesMut::new(); + buf.put_u8(FEA_EXT_FEDAUTH); + buf.put_u32_le(32); + buf.extend_from_slice(&[7u8; 32]); + buf.put_u8(FEA_EXT_TERMINATOR); + + let ack = TokenFeatureExtAck::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(ack.features.len(), 1); + match &ack.features[0] { + FeatureAck::FedAuth(FedAuthAck::SecurityToken { nonce }) => { + assert_eq!(*nonce, Some([7u8; 32])); + } + } + } + + #[tokio::test] + async fn decodes_fedauth_without_nonce() { + let mut buf = BytesMut::new(); + buf.put_u8(FEA_EXT_FEDAUTH); + buf.put_u32_le(0); + buf.put_u8(FEA_EXT_TERMINATOR); + + let ack = TokenFeatureExtAck::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + match &ack.features[0] { + FeatureAck::FedAuth(FedAuthAck::SecurityToken { nonce }) => { + assert!(nonce.is_none()); + } + } + } + + #[tokio::test] + async fn decode_rejects_invalid_data_length() { + // A FEDAUTH ack with a data length that is neither 0 nor 32 is invalid. + let mut buf = BytesMut::new(); + buf.put_u8(FEA_EXT_FEDAUTH); + buf.put_u32_le(5); + buf.extend_from_slice(&[0u8; 5]); + + let err = TokenFeatureExtAck::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("invalid data length must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_rejects_unsupported_feature() { + // A feature id that is neither the terminator nor FEDAUTH is unsupported. + let mut buf = BytesMut::new(); + buf.put_u8(0x99); + + let err = TokenFeatureExtAck::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("unsupported feature must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn empty_feature_list() { + let mut buf = BytesMut::new(); + buf.put_u8(FEA_EXT_TERMINATOR); + + let ack = TokenFeatureExtAck::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert!(ack.features.is_empty()); + } +} diff --git a/src/tds/codec/token/token_fed_auth_info.rs b/src/tds/codec/token/token_fed_auth_info.rs index 8ba3022d9..b8f5441e3 100644 --- a/src/tds/codec/token/token_fed_auth_info.rs +++ b/src/tds/codec/token/token_fed_auth_info.rs @@ -41,6 +41,12 @@ impl TokenFedAuthInfo { // starting at (and including) `CountOfInfoIDs`. let token_length = src.read_u32_le().await? as usize; + if token_length > super::MAX_TOKEN_BODY { + return Err(Error::Protocol( + format!("FEDAUTHINFO token length {token_length} exceeds the maximum").into(), + )); + } + let mut body = vec![0u8; token_length]; src.read_exact(&mut body).await?; @@ -184,4 +190,128 @@ mod tests { assert!(TokenFedAuthInfo::parse(&body).is_err()); } + + #[tokio::test] + async fn decode_reads_length_prefix_and_parses_body() { + // Exercises the full `decode` path: reading the 4-byte TokenLength, the + // length bound check, reading the body, and parsing it. A mutation that + // short-circuits `decode` to `Ok(Default::default())` would drop the + // parsed STSURL, and a `<` mutation of the length bound check would + // reject this (well-under-maximum) token outright. + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + let sts = utf16le("https://sts.example/"); + + let count: u32 = 1; + let header_len = 4 + 9; // count + one FedAuthInfoOpt + let sts_offset = header_len; + + let mut body = Vec::new(); + body.extend_from_slice(&count.to_le_bytes()); + body.push(FED_AUTH_INFO_ID_STSURL); + body.extend_from_slice(&(sts.len() as u32).to_le_bytes()); + body.extend_from_slice(&(sts_offset as u32).to_le_bytes()); + body.extend_from_slice(&sts); + + let mut buf = BytesMut::new(); + buf.put_u32_le(body.len() as u32); // TokenLength + buf.put_slice(&body); + + let info = TokenFedAuthInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(info.sts_url.as_deref(), Some("https://sts.example/")); + assert_eq!(info.spn, None); + } + + #[tokio::test] + async fn decode_rejects_oversized_token_length() { + // A TokenLength above MAX_TOKEN_BODY must be rejected before any body is + // read (the `token_length > MAX_TOKEN_BODY` guard). + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + let mut buf = BytesMut::new(); + buf.put_u32_le((super::super::MAX_TOKEN_BODY + 1) as u32); + + let err = TokenFedAuthInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("oversized token length must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_rejects_truncated_dword() { + // A body too short to even read CountOfInfoIDs (a DWORD) trips the + // `read_u32` truncation guard. + let err = TokenFedAuthInfo::parse(&[0u8, 0u8]).expect_err("truncated body must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_rejects_missing_option_id() { + // CountOfInfoIDs claims one option, but the body ends right after the + // count, so reading the option id is out of bounds. + let mut body = Vec::new(); + body.extend_from_slice(&1u32.to_le_bytes()); + + let err = TokenFedAuthInfo::parse(&body).expect_err("missing option id must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_rejects_odd_data_length() { + // A data length that is not a multiple of two cannot be valid UTF-16. + let count: u32 = 1; + let header_len = 4 + 9; + let mut body = Vec::new(); + body.extend_from_slice(&count.to_le_bytes()); + body.push(FED_AUTH_INFO_ID_STSURL); + body.extend_from_slice(&3u32.to_le_bytes()); // odd data len + body.extend_from_slice(&(header_len as u32).to_le_bytes()); // offset + body.extend_from_slice(&[0u8, 0u8, 0u8]); // 3 data bytes + + let err = TokenFedAuthInfo::parse(&body).expect_err("odd data length must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_rejects_invalid_utf16() { + // Even-length but not valid UTF-16 (a lone high surrogate) must error. + let count: u32 = 1; + let header_len = 4 + 9; + let mut body = Vec::new(); + body.extend_from_slice(&count.to_le_bytes()); + body.push(FED_AUTH_INFO_ID_STSURL); + body.extend_from_slice(&2u32.to_le_bytes()); // data len + body.extend_from_slice(&(header_len as u32).to_le_bytes()); // offset + body.extend_from_slice(&0xD800u16.to_le_bytes()); // lone high surrogate + + let err = TokenFedAuthInfo::parse(&body).expect_err("invalid UTF-16 must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_accepts_token_length_at_maximum() { + // The length bound check is `token_length > MAX_TOKEN_BODY`, so a token + // whose length is exactly MAX_TOKEN_BODY must be accepted. `>=` or `==` + // mutations of the `>` would reject it. The body is a valid, empty + // (CountOfInfoIDs == 0) token padded out to the maximum length. + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + let token_length = super::super::MAX_TOKEN_BODY; + + let mut buf = BytesMut::new(); + buf.put_u32_le(token_length as u32); // TokenLength == MAX_TOKEN_BODY + buf.put_slice(&vec![0u8; token_length]); // count = 0, rest padding + + let info = TokenFedAuthInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(info, TokenFedAuthInfo::default()); + } } diff --git a/src/tds/codec/token/token_info.rs b/src/tds/codec/token/token_info.rs index 96c4eca53..9557a2ba4 100644 --- a/src/tds/codec/token/token_info.rs +++ b/src/tds/codec/token/token_info.rs @@ -1,4 +1,4 @@ -use crate::SqlReadBytes; +use crate::{tds::codec::FeatureLevel, SqlReadBytes}; #[allow(dead_code)] // we might want to debug the values #[derive(Debug)] @@ -28,7 +28,15 @@ impl TokenInfo { let message = src.read_us_varchar().await?; let server = src.read_b_varchar().await?; let procedure = src.read_b_varchar().await?; - let line = src.read_u32_le().await?; + // MS-TDS 2.2.7.13: like ERROR, INFO's LineNumber is a 4-byte LONG for + // TDS 7.2 (SQL Server 2005) and later, and a 2-byte USHORT before that. + // Reading a fixed u32 over-reads 2 bytes against a TDS 7.1 server and + // desyncs the token stream. + let line = if src.context().version() >= FeatureLevel::SqlServer2005 { + src.read_u32_le().await? + } else { + src.read_u16_le().await? as u32 + }; Ok(TokenInfo { number, @@ -41,3 +49,74 @@ impl TokenInfo { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + fn put_b_varchar(buf: &mut BytesMut, s: &str) { + let utf16: Vec = s.encode_utf16().collect(); + buf.put_u8(utf16.len() as u8); + for c in utf16 { + buf.put_u16_le(c); + } + } + + fn put_us_varchar(buf: &mut BytesMut, s: &str) { + let utf16: Vec = s.encode_utf16().collect(); + buf.put_u16_le(utf16.len() as u16); + for c in utf16 { + buf.put_u16_le(c); + } + } + + #[tokio::test] + async fn decodes_all_fields() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); // length, ignored + buf.put_u32_le(4711); + buf.put_u8(2); + buf.put_u8(9); + put_us_varchar(&mut buf, "informational"); + put_b_varchar(&mut buf, "server"); + put_b_varchar(&mut buf, "proc"); + buf.put_u32_le(123); + + let info = TokenInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(info.number, 4711); + assert_eq!(info.state, 2); + assert_eq!(info.class, 9); + assert_eq!(info.message, "informational"); + assert_eq!(info.server, "server"); + assert_eq!(info.procedure, "proc"); + assert_eq!(info.line, 123); + } + + #[tokio::test] + async fn decode_reads_full_four_byte_line_number_on_tds72_plus() { + // The default test context reports SqlServerN (>= TDS 7.2), so the + // LineNumber must be read as a 4-byte LONG. A `>` mutation of the `>=` + // boundary check would read only 2 bytes. 0x0001_0001 (65537) reads as 1 + // when truncated to 2 bytes but as 65537 when read correctly as 4 bytes. + let mut buf = BytesMut::new(); + buf.put_u16_le(0); // length, ignored + buf.put_u32_le(4711); + buf.put_u8(2); + buf.put_u8(9); + put_us_varchar(&mut buf, "informational"); + put_b_varchar(&mut buf, "server"); + put_b_varchar(&mut buf, "proc"); + buf.put_u32_le(0x0001_0001); + + let info = TokenInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(info.line, 0x0001_0001); + } +} diff --git a/src/tds/codec/token/token_login_ack.rs b/src/tds/codec/token/token_login_ack.rs index 28a4bfc40..cac3475a9 100644 --- a/src/tds/codec/token/token_login_ack.rs +++ b/src/tds/codec/token/token_login_ack.rs @@ -38,3 +38,51 @@ impl TokenLoginAck { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + fn put_b_varchar(buf: &mut BytesMut, s: &str) { + let utf16: Vec = s.encode_utf16().collect(); + buf.put_u8(utf16.len() as u8); + for c in utf16 { + buf.put_u16_le(c); + } + } + + #[tokio::test] + async fn decodes_valid_ack() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); // length, ignored + buf.put_u8(1); // interface + buf.put_u32(FeatureLevel::SqlServerN as u32); // big-endian tds version + put_b_varchar(&mut buf, "Microsoft SQL Server"); + buf.put_u32_le(0x0F00_0FA0); // version + + let ack = TokenLoginAck::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(ack.interface, 1); + assert_eq!(ack.tds_version, FeatureLevel::SqlServerN); + assert_eq!(ack.prog_name, "Microsoft SQL Server"); + assert_eq!(ack.version, 0x0F00_0FA0); + } + + #[tokio::test] + async fn invalid_tds_version_errors() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); + buf.put_u8(1); + buf.put_u32(0xDEAD_BEEF); // not a valid FeatureLevel + + let err = TokenLoginAck::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("must fail on invalid version"); + + assert!(matches!(err, Error::Protocol(_))); + } +} diff --git a/src/tds/codec/token/token_order.rs b/src/tds/codec/token/token_order.rs index d39dfdbb2..7f6532f01 100644 --- a/src/tds/codec/token/token_order.rs +++ b/src/tds/codec/token/token_order.rs @@ -13,7 +13,10 @@ impl TokenOrder { { let len = src.read_u16_le().await? / 2; - let mut column_indexes = Vec::with_capacity(len as usize); + // `len` is derived from an untrusted u16; cap the up-front reservation + // (the Vec still grows as indexes are actually read). + let mut column_indexes = + Vec::with_capacity((len as usize).min(crate::tds::codec::column_data::MAX_PREALLOC)); for _ in 0..len { column_indexes.push(src.read_u16_le().await?); @@ -22,3 +25,38 @@ impl TokenOrder { Ok(TokenOrder { column_indexes }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + #[tokio::test] + async fn decodes_column_indexes() { + let mut buf = BytesMut::new(); + // length is in bytes; three u16 indexes => 6 bytes + buf.put_u16_le(6); + buf.put_u16_le(1); + buf.put_u16_le(2); + buf.put_u16_le(3); + + let order = TokenOrder::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(order.column_indexes, vec![1, 2, 3]); + } + + #[tokio::test] + async fn decodes_empty() { + let mut buf = BytesMut::new(); + buf.put_u16_le(0); + + let order = TokenOrder::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + + assert!(order.column_indexes.is_empty()); + } +} diff --git a/src/tds/codec/token/token_return_value.rs b/src/tds/codec/token/token_return_value.rs index 183e46be0..82d1de8d4 100644 --- a/src/tds/codec/token/token_return_value.rs +++ b/src/tds/codec/token/token_return_value.rs @@ -40,3 +40,65 @@ impl TokenReturnValue { Ok(token) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use crate::tds::codec::{Encode, FixedLenType, TypeInfo}; + use bytes::{BufMut, BytesMut}; + + fn put_b_varchar(buf: &mut BytesMut, s: &str) { + let utf16: Vec = s.encode_utf16().collect(); + buf.put_u8(utf16.len() as u8); + for c in utf16 { + buf.put_u16_le(c); + } + } + + fn build(status: u8) -> BytesMut { + let mut buf = BytesMut::new(); + buf.put_u16_le(1); // param ordinal + put_b_varchar(&mut buf, "@out"); + buf.put_u8(status); + + // BaseMetaDataColumn: user_ty, flags, type info + buf.put_u32_le(0); + buf.put_u16_le(0); + TypeInfo::FixedLen(FixedLenType::Int4) + .encode(&mut buf) + .unwrap(); + + // value payload (i32) + buf.put_i32_le(42); + buf + } + + #[tokio::test] + async fn decodes_non_udf_value() { + let token = TokenReturnValue::decode(&mut build(0x01).into_sql_read_bytes()) + .await + .unwrap(); + + assert_eq!(token.param_ordinal, 1); + assert_eq!(token.param_name, "@out"); + assert!(!token.udf); + assert_eq!(token.value, ColumnData::I32(Some(42))); + } + + #[tokio::test] + async fn decodes_udf_flag() { + let token = TokenReturnValue::decode(&mut build(0x02).into_sql_read_bytes()) + .await + .unwrap(); + assert!(token.udf); + } + + #[tokio::test] + async fn invalid_status_errors() { + let err = TokenReturnValue::decode(&mut build(0x00).into_sql_read_bytes()) + .await + .expect_err("invalid status must fail"); + assert!(matches!(err, Error::Protocol(_))); + } +} diff --git a/src/tds/codec/token/token_row.rs b/src/tds/codec/token/token_row.rs index 5abf2572e..7117c27a9 100644 --- a/src/tds/codec/token/token_row.rs +++ b/src/tds/codec/token/token_row.rs @@ -101,7 +101,9 @@ impl TokenRow<'static> { where R: SqlReadBytes + Unpin, { - let col_meta = src.context().last_meta().unwrap(); + let col_meta = src.context().last_meta().ok_or_else(|| { + crate::Error::Protocol("ROW token arrived before any COLMETADATA".into()) + })?; let mut row = Self { data: Vec::with_capacity(col_meta.columns.len()), @@ -121,7 +123,9 @@ impl TokenRow<'static> { where R: SqlReadBytes + Unpin, { - let col_meta = src.context().last_meta().unwrap(); + let col_meta = src.context().last_meta().ok_or_else(|| { + crate::Error::Protocol("NBCROW token arrived before any COLMETADATA".into()) + })?; let row_bitmap = RowBitmap::decode(src, col_meta.columns.len()).await?; let mut row = Self { @@ -208,4 +212,181 @@ mod tests { row.encode(&mut buf_with_columns) .expect_err("wrong number of columns"); } + + #[tokio::test] + async fn row_before_colmetadata_is_protocol_error() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + // No COLMETADATA has been seen, so last_meta() is None: decoding a ROW + // must be a protocol error rather than an unwrap() panic. + let buf = BytesMut::new(); + let err = TokenRow::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("ROW before COLMETADATA must error"); + assert!(matches!(err, crate::Error::Protocol(_))); + } + + #[test] + fn basic_container_operations() { + let mut row = TokenRow::new(); + assert!(row.is_empty()); + assert_eq!(row.len(), 0); + assert_eq!(row.get(0), None); + + row.push(ColumnData::I32(Some(1))); + row.push(ColumnData::I32(Some(2))); + assert_eq!(row.len(), 2); + assert!(!row.is_empty()); + assert_eq!(row.get(0), Some(&ColumnData::I32(Some(1)))); + assert_eq!(row.get(5), None); + + let collected: Vec<_> = row.iter().collect(); + assert_eq!(collected.len(), 2); + + row.clear(); + assert!(row.is_empty()); + + let with_cap = TokenRow::with_capacity(4); + assert!(with_cap.is_empty()); + } + + #[test] + fn with_capacity_preallocates() { + // with_capacity must actually reserve room; Default::default() would + // give a zero-capacity vec. + let row = TokenRow::with_capacity(16); + assert!(row.is_empty()); + assert!(row.data.capacity() >= 16); + } + + #[test] + fn row_bitmap_is_null_checks_correct_bit() { + // Only bit 3 is set in the single bitmap byte. is_null must consult that + // exact bit; a `<<`->`>>` mutation would look at bit -3 (i.e. 0) and + // report the wrong columns. + let bitmap = RowBitmap { + data: vec![0b0000_1000], + }; + + assert!(bitmap.is_null(3)); + assert!(!bitmap.is_null(0)); + assert!(!bitmap.is_null(1)); + assert!(!bitmap.is_null(2)); + assert!(!bitmap.is_null(4)); + } + + #[test] + fn into_iter_yields_owned_values() { + let mut row = TokenRow::new(); + row.push(ColumnData::I32(Some(1))); + row.push(ColumnData::I32(Some(2))); + + let values: Vec<_> = row.into_iter().collect(); + assert_eq!( + values, + vec![ColumnData::I32(Some(1)), ColumnData::I32(Some(2))] + ); + } + + #[tokio::test] + async fn encode_matching_columns_round_trip() { + let row = (true, 5i32).into_row(); + let columns = vec![ + MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty: TypeInfo::FixedLen(FixedLenType::Bit), + }, + col_name: Default::default(), + }, + MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }, + col_name: Default::default(), + }, + ]; + let mut buf = BytesMut::new(); + let mut buf_with_columns = BytesMutWithDataColumns::new(&mut buf, &columns); + + row.encode(&mut buf_with_columns).unwrap(); + assert!(!buf.is_empty()); + } + + #[tokio::test] + async fn decode_reads_columns_from_cached_meta() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use crate::tds::codec::TokenColMetaData; + use std::sync::Arc; + + let col_meta = TokenColMetaData { + columns: vec![MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }, + col_name: Default::default(), + }], + }; + + let mut buf = BytesMut::new(); + buf.put_i32_le(42); + + let mut reader = buf.into_sql_read_bytes(); + reader.context_mut().set_last_meta(Arc::new(col_meta)); + + let row = TokenRow::decode(&mut reader).await.unwrap(); + assert_eq!(row.len(), 1); + assert_eq!(row.get(0), Some(&ColumnData::I32(Some(42)))); + } + + #[tokio::test] + async fn decode_nbc_before_colmetadata_is_protocol_error() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + let buf = BytesMut::new(); + let err = TokenRow::decode_nbc(&mut buf.into_sql_read_bytes()) + .await + .expect_err("NBCROW before COLMETADATA must error"); + assert!(matches!(err, crate::Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_nbc_uses_bitmap_for_nulls() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use crate::tds::codec::TokenColMetaData; + use std::sync::Arc; + + // Two int columns: first null (bit 0 set), second present (value 7). + let col_meta = TokenColMetaData { + columns: vec![ + MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }, + col_name: Default::default(), + }, + MetaDataColumn { + base: BaseMetaDataColumn { + flags: ColumnFlag::Nullable.into(), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }, + col_name: Default::default(), + }, + ], + }; + + let mut buf = BytesMut::new(); + buf.put_u8(0b0000_0001); // bitmap: column 0 is null + buf.put_i32_le(7); // column 1's value + + let mut reader = buf.into_sql_read_bytes(); + reader.context_mut().set_last_meta(Arc::new(col_meta)); + + let row = TokenRow::decode_nbc(&mut reader).await.unwrap(); + assert_eq!(row.len(), 2); + assert_eq!(row.get(0), Some(&ColumnData::I32(None))); + assert_eq!(row.get(1), Some(&ColumnData::I32(Some(7)))); + } } diff --git a/src/tds/codec/token/token_row/into_row.rs b/src/tds/codec/token/token_row/into_row.rs index 8bee0dcd4..c0615d351 100644 --- a/src/tds/codec/token/token_row/into_row.rs +++ b/src/tds/codec/token/token_row/into_row.rs @@ -205,3 +205,58 @@ where row } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::codec::ColumnData; + + #[test] + fn single_value_into_row() { + let row = 42i32.into_row(); + assert_eq!(row.len(), 1); + assert_eq!(row.get(0), Some(&ColumnData::I32(Some(42)))); + } + + #[test] + fn tuple_arities_produce_expected_lengths_and_order() { + assert_eq!((1i32, 2i32).into_row().len(), 2); + assert_eq!((1i32, 2i32, 3i32).into_row().len(), 3); + assert_eq!((1i32, 2i32, 3i32, 4i32).into_row().len(), 4); + assert_eq!((1i32, 2i32, 3i32, 4i32, 5i32).into_row().len(), 5); + assert_eq!((1i32, 2i32, 3i32, 4i32, 5i32, 6i32).into_row().len(), 6); + assert_eq!( + (1i32, 2i32, 3i32, 4i32, 5i32, 6i32, 7i32).into_row().len(), + 7 + ); + assert_eq!( + (1i32, 2i32, 3i32, 4i32, 5i32, 6i32, 7i32, 8i32) + .into_row() + .len(), + 8 + ); + assert_eq!( + (1i32, 2i32, 3i32, 4i32, 5i32, 6i32, 7i32, 8i32, 9i32) + .into_row() + .len(), + 9 + ); + + let row = (1i32, 2i32, 3i32, 4i32, 5i32, 6i32, 7i32, 8i32, 9i32, 10i32).into_row(); + assert_eq!(row.len(), 10); + + // Values are pushed in tuple order. + for (i, value) in row.iter().enumerate() { + assert_eq!(value, &ColumnData::I32(Some(i as i32 + 1))); + } + } + + #[test] + fn mixed_types_preserve_positions() { + let row = (true, 7u8, "hello", 3.5f64).into_row(); + assert_eq!(row.len(), 4); + assert_eq!(row.get(0), Some(&ColumnData::Bit(Some(true)))); + assert_eq!(row.get(1), Some(&ColumnData::U8(Some(7)))); + assert_eq!(row.get(3), Some(&ColumnData::F64(Some(3.5)))); + } +} diff --git a/src/tds/codec/token/token_session_state.rs b/src/tds/codec/token/token_session_state.rs index bf399724f..d974d99fc 100644 --- a/src/tds/codec/token/token_session_state.rs +++ b/src/tds/codec/token/token_session_state.rs @@ -71,6 +71,23 @@ impl TokenSessionState { short_len as usize }; + // `state_len` (up to a full u32 via the 0xFF LONG escape) is + // untrusted. Even though the outer token body is capped at + // MAX_TOKEN_BODY, a single entry could still declare ~4GiB while the + // token itself is only a few bytes on the wire. Reject any length + // that cannot possibly fit in the remaining buffered bytes before + // allocating, so `vec![0u8; state_len]` can't be used for + // memory exhaustion. + let remaining = total - buf.position(); + if state_len as u64 > remaining { + return Err(Error::Protocol( + format!( + "SESSIONSTATE entry length {state_len} exceeds the {remaining} bytes remaining in the token" + ) + .into(), + )); + } + let mut value = vec![0u8; state_len]; buf.read_exact(&mut value)?; @@ -92,6 +109,12 @@ impl TokenSessionState { // Status and all SessionStateData entries. let len = src.read_u32_le().await? as usize; + if len > super::MAX_TOKEN_BODY { + return Err(Error::Protocol( + format!("SESSIONSTATE token length {len} exceeds the maximum").into(), + )); + } + let mut bytes = vec![0u8; len]; src.read_exact(&mut bytes[0..len]).await?; @@ -163,4 +186,108 @@ mod tests { assert_eq!(token.states[0].value.len(), 300); assert!(token.states[0].value.iter().all(|&b| b == 0x5A)); } + + #[test] + fn parse_rejects_oversized_state_len() { + // A single entry whose declared StateLen (~4GiB via the 0xFF escape) far + // exceeds the bytes actually present must error, not attempt the + // allocation. + let mut body = Vec::new(); + body.extend_from_slice(&1u32.to_le_bytes()); // SeqNo + body.push(0x00); // Status + body.push(0x01); // StateId + body.push(0xFF); // long-length escape + body.extend_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); // StateLen ~4GiB + // ...but no value bytes follow. + + let err = TokenSessionState::parse(body).expect_err("oversized StateLen must be rejected"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_rejects_state_len_exceeding_remaining() { + // StateLen (5) is larger than the bytes actually remaining after the + // header (3), so it must be rejected as a protocol error. This exercises + // `remaining = total - position`: a `-`->`+` mutation would compute a + // much larger "remaining" and wrongly accept the length (then fail later + // with an I/O error instead). + let mut body = Vec::new(); + body.extend_from_slice(&1u32.to_le_bytes()); // SeqNo + body.push(0x00); // Status + body.push(0x00); // StateId + body.push(0x05); // StateLen = 5 + body.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // only 3 value bytes present + + let err = TokenSessionState::parse(body) + .expect_err("StateLen exceeding remaining bytes must be rejected"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_accepts_minimum_and_larger_lengths() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + // len == 5: exactly SeqNo + Status, no states. The `bytes.len() < 5` + // check must NOT reject this boundary (kills `<`->`<=`/`==`). + let mut buf = BytesMut::new(); + buf.put_u32_le(5); + buf.put_u32_le(1); // SeqNo + buf.put_u8(0x01); // Status + + let token = TokenSessionState::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert_eq!(token.seq_no, 1); + assert_eq!(token.status, 0x01); + assert!(token.states.is_empty()); + + // len == 8: a full token with one state value. Must decode fine (kills + // `<`->`>`, which would reject lengths above 5). + let mut buf = BytesMut::new(); + buf.put_u32_le(8); + buf.put_u32_le(2); // SeqNo + buf.put_u8(0x00); // Status + buf.put_u8(0x07); // StateId + buf.put_u8(0x01); // StateLen = 1 + buf.put_u8(0x42); // value + + let token = TokenSessionState::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert_eq!(token.seq_no, 2); + assert_eq!(token.states.len(), 1); + assert_eq!(token.states[0].id, 7); + assert_eq!(token.states[0].value, vec![0x42]); + } + + #[tokio::test] + async fn decode_length_boundary_against_max_token_body() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + use bytes::{BufMut, BytesMut}; + + // len == MAX_TOKEN_BODY + 1: over the cap, so a protocol error is + // returned immediately (kills `>`->`<`/`==`, which would not trip here + // and would instead fail later with an I/O error). + let mut buf = BytesMut::new(); + buf.put_u32_le((super::super::MAX_TOKEN_BODY + 1) as u32); + buf.put_u32_le(0); // a few bytes so the read gets that far + + let err = TokenSessionState::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("length over MAX_TOKEN_BODY must be a protocol error"); + assert!(matches!(err, Error::Protocol(_))); + + // len == MAX_TOKEN_BODY exactly: at the boundary the length check must + // NOT fire (kills `>`->`>=`). The buffer is truncated, so the real code + // proceeds past the check and fails with an I/O error instead. + let mut buf = BytesMut::new(); + buf.put_u32_le(super::super::MAX_TOKEN_BODY as u32); + buf.put_u32_le(0); // far fewer than MAX bytes follow + + let err = TokenSessionState::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("truncated body must fail after the length check"); + assert!(matches!(err, Error::Io { .. })); + } } diff --git a/src/tds/codec/token/token_tab_name.rs b/src/tds/codec/token/token_tab_name.rs index 45c8523e2..d64bb51df 100644 --- a/src/tds/codec/token/token_tab_name.rs +++ b/src/tds/codec/token/token_tab_name.rs @@ -175,6 +175,39 @@ mod tests { assert!(token.tables().is_empty()); } + #[test] + fn parse_non_ascii_name_reads_both_bytes_of_each_unit() { + // A code point with a non-zero high byte (U+20AC EURO SIGN => 0xAC 0x20) + // only decodes correctly if both bytes of the UTF-16 unit are read; a + // one-off in the low/high byte index would corrupt it. + let mut data = vec![1u8]; + data.extend_from_slice(&us_varchar("€uro")); + + let token = TokenTabName::parse(&data).expect("must parse"); + assert_eq!(token.tables()[0].parts(), &["€uro".to_string()]); + } + + #[test] + fn parse_zero_length_part_at_buffer_end() { + // NumParts = 1 followed by a zero-length part that ends exactly at the + // buffer boundary: the `pos + 2 > len` check must accept (not reject) an + // exact fit. + let data = vec![1u8, 0u8, 0u8]; + let token = TokenTabName::parse(&data).expect("exact-fit length must parse"); + assert_eq!(token.tables()[0].parts(), &[String::new()]); + } + + #[test] + fn parse_rejects_name_length_exceeding_payload() { + // NumParts = 1, part claims 4 code units (8 bytes) but only 6 follow. + // The `char_count * 2` byte check must reject this; a wrong multiplier + // would under-count and read past the buffer. + let mut data = vec![1u8]; + data.extend_from_slice(&4u16.to_le_bytes()); + data.extend_from_slice(&[0xAB; 6]); + assert!(TokenTabName::parse(&data).is_err()); + } + #[test] fn parse_truncated_length_fails() { // NumParts says 1 part but no length bytes follow. diff --git a/src/tds/codec/type_info.rs b/src/tds/codec/type_info.rs index 960c67b21..998ad142a 100644 --- a/src/tds/codec/type_info.rs +++ b/src/tds/codec/type_info.rs @@ -115,11 +115,16 @@ impl Encode for VarLenContext { // length match self.r#type { + // DATE (0x28) carries NO scale byte in TYPE_INFO (MS-TDS + // §2.2.5.4.2 / §2.2.5.5.1.2), unlike TIME/DATETIME2/DATETIMEOFFSET + // which each carry a SCALE byte. The decoder already special-cases + // this (`Daten => 3`, reading no byte); emitting a byte here would + // desync every field after a `date` column in a TYPE_INFO stream + // (bulk-load column metadata / TVP). #[cfg(feature = "tds73")] - VarLenType::Daten - | VarLenType::Timen - | VarLenType::DatetimeOffsetn - | VarLenType::Datetime2 => { + VarLenType::Daten => {} + #[cfg(feature = "tds73")] + VarLenType::Timen | VarLenType::DatetimeOffsetn | VarLenType::Datetime2 => { dst.put_u8(self.len() as u8); } VarLenType::Bitn @@ -144,7 +149,11 @@ impl Encode for VarLenContext { dst.put_u32_le(self.len() as u32); } VarLenType::Xml => (), - typ => todo!("encoding {:?} is not supported yet", typ), + typ => { + return Err(Error::Protocol( + format!("encoding a {typ:?} var-len context is not supported").into(), + )) + } } if let Some(collation) = self.collation() { @@ -194,12 +203,12 @@ uint_enum! { NVarchar = 0xE7, NChar = 0xEF, Xml = 0xF1, - // not supported yet + // CLR user-defined type; decoded as raw PLP bytes (see column_data/udt.rs). Udt = 0xF0, Text = 0x23, Image = 0x22, NText = 0x63, - // not supported yet + // sql_variant; fully decoded/encoded (see column_data/sql_variant.rs). SSVariant = 0x62, // legacy types (not supported since post-7.2): // Char = 0x2F, // Binary = 0x2D, @@ -234,12 +243,12 @@ uint_enum! { NVarchar = 0xE7, NChar = 0xEF, Xml = 0xF1, - // not supported yet + // CLR user-defined type; decoded as raw PLP bytes (see column_data/udt.rs). Udt = 0xF0, Text = 0x23, Image = 0x22, NText = 0x63, - // not supported yet + // sql_variant; fully decoded/encoded (see column_data/sql_variant.rs). SSVariant = 0x62, // legacy types (not supported since post-7.2): // Char = 0x2F, // Binary = 0x2D, @@ -404,7 +413,11 @@ impl TypeInfo { | VarLenType::Text | VarLenType::NText | VarLenType::SSVariant => src.read_u32_le().await? as usize, - _ => todo!("not yet implemented for {:?}", ty), + _ => { + return Err(Error::Protocol( + format!("unsupported column type in COLMETADATA: {:?}", ty).into(), + )) + } }; let collation = match ty { @@ -427,6 +440,18 @@ impl TypeInfo { let precision = src.read_u8().await?; let scale = src.read_u8().await?; + // MS-TDS: precision is 1..=38 and scale 0..=precision. + // Reject out-of-range server values here so downstream + // (Numeric decode/Display) never sees an impossible scale. + if precision > 38 || scale > precision { + return Err(Error::Protocol( + format!( + "decimal/numeric: invalid precision {precision} / scale {scale}" + ) + .into(), + )); + } + TypeInfo::VarLenSizedPrecision { size: len, ty, @@ -494,4 +519,196 @@ mod tests { assert_eq!(nti, ti) } } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn date_typeinfo_round_trips_without_scale_byte() { + // DATE (0x28) has no scale byte in TYPE_INFO: encode must emit only the + // type token, and it must round-trip through decode. + let ti = TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Daten, 3, None)); + let mut buf = BytesMut::new(); + ti.clone().encode(&mut buf).expect("encode must succeed"); + + assert_eq!(buf.as_ref(), &[VarLenType::Daten as u8]); + + let nti = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("decode must succeed"); + assert_eq!(nti, ti); + } + + #[tokio::test] + async fn decode_rejects_out_of_range_precision_scale() { + // Decimaln TYPE_INFO: [type][size][precision][scale]. A precision > 38 + // from an untrusted server must be rejected rather than flowing into + // Numeric decoding (which would later panic on an impossible scale). + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Decimaln as u8); + buf.put_u8(17); // size + buf.put_u8(200); // precision (invalid, > 38) + buf.put_u8(2); // scale + + let err = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("out-of-range precision must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn var_len_context_is_empty() { + assert!(VarLenContext::new(VarLenType::Intn, 0, None).is_empty()); + assert!(!VarLenContext::new(VarLenType::Intn, 4, None).is_empty()); + } + + #[tokio::test] + async fn decode_intn_reads_one_byte_length() { + // Covers the Bitn|Intn|Floatn|... match arm: the length is a single u8. + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Intn as u8); + buf.put_u8(4); // length in bytes + + let ti = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("decode must succeed"); + assert_eq!( + ti, + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 4, None)) + ); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn decode_timen_reads_one_byte_scale() { + // Covers the Timen|DatetimeOffsetn|Datetime2 match arm: reads a u8 scale + // as the length. Deleting the arm would make this an error. + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Timen as u8); + buf.put_u8(7); // scale + + let ti = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("decode must succeed"); + assert_eq!( + ti, + TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Timen, 7, None)) + ); + } + + #[tokio::test] + async fn decode_accepts_precision_38_and_scale_below_precision() { + // Boundary: precision == 38 is the maximum valid precision and must be + // accepted; scale (10) is below precision. + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Decimaln as u8); + buf.put_u8(17); // size + buf.put_u8(38); // precision (max valid) + buf.put_u8(10); // scale + + let ti = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("precision 38 must be accepted"); + assert_eq!( + ti, + TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Decimaln, + size: 17, + precision: 38, + scale: 10, + } + ); + } + + #[tokio::test] + async fn decode_accepts_scale_equal_to_precision() { + // Boundary: scale == precision is valid (scale may equal precision). + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Numericn as u8); + buf.put_u8(17); // size + buf.put_u8(20); // precision + buf.put_u8(20); // scale == precision + + let ti = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("scale == precision must be accepted"); + assert_eq!( + ti, + TypeInfo::VarLenSizedPrecision { + ty: VarLenType::Numericn, + size: 17, + precision: 20, + scale: 20, + } + ); + } + + #[test] + fn var_len_context_encode_xml_emits_only_type_byte() { + // Xml in a VarLenContext carries no length bytes: encode must emit only + // the type token (the `VarLenType::Xml => ()` arm). + let mut buf = BytesMut::new(); + VarLenContext::new(VarLenType::Xml, 0, None) + .encode(&mut buf) + .expect("encode must succeed"); + assert_eq!(buf.as_ref(), &[VarLenType::Xml as u8]); + } + + #[test] + fn var_len_context_encode_unsupported_type_errors() { + // Udt is not encodable through VarLenContext (it has its own TypeInfo + // arm), so it hits the `typ => Err(..)` fallback. + let mut buf = BytesMut::new(); + let err = VarLenContext::new(VarLenType::Udt, 0, None) + .encode(&mut buf) + .expect_err("encoding a Udt var-len context must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_rejects_invalid_type_byte() { + // A leading byte that is neither a FixedLenType nor a VarLenType must be + // rejected (`Err(())` arm of the VarLenType match). + let mut buf = BytesMut::new(); + buf.put_u8(0x00); + + let err = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("invalid type byte must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_udt_info_round_trips() { + // Exercises the UDT_INFO decode arm: max_byte_size + three b_varchars + + // a us_varchar assembly-qualified name. + let ti = TypeInfo::Udt(UdtInfo { + max_byte_size: 0xffff, + db_name: "db".to_string(), + schema_name: "dbo".to_string(), + type_name: "geometry".to_string(), + assembly_qualified_name: "asm".to_string(), + }); + + let mut buf = BytesMut::new(); + ti.clone().encode(&mut buf).expect("encode must succeed"); + + let decoded = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect("decode must succeed"); + assert_eq!(decoded, ti); + } + + #[tokio::test] + async fn decode_rejects_scale_greater_than_precision() { + // scale > precision must be rejected. + let mut buf = BytesMut::new(); + buf.put_u8(VarLenType::Decimaln as u8); + buf.put_u8(17); // size + buf.put_u8(10); // precision + buf.put_u8(20); // scale > precision + + let err = TypeInfo::decode(&mut buf.into_sql_read_bytes()) + .await + .expect_err("scale > precision must error"); + assert!(matches!(err, Error::Protocol(_))); + } } diff --git a/src/tds/codec/type_info_tvp.rs b/src/tds/codec/type_info_tvp.rs index a6d9b717c..ec8e7df8d 100644 --- a/src/tds/codec/type_info_tvp.rs +++ b/src/tds/codec/type_info_tvp.rs @@ -203,4 +203,76 @@ mod tests { )); assert!(fixed_to_var_len(FixedLenType::Null).is_none()); } + + #[test] + fn rewrites_all_fixed_len_variants() { + use super::super::VarLenType; + + let cases = [ + (FixedLenType::Int1, VarLenType::Intn, 1), + (FixedLenType::Bit, VarLenType::Bitn, 1), + (FixedLenType::Int2, VarLenType::Intn, 2), + (FixedLenType::Int4, VarLenType::Intn, 4), + (FixedLenType::Datetime4, VarLenType::Datetimen, 4), + (FixedLenType::Float4, VarLenType::Floatn, 4), + (FixedLenType::Money, VarLenType::Money, 8), + (FixedLenType::Datetime, VarLenType::Datetimen, 8), + (FixedLenType::Float8, VarLenType::Floatn, 8), + (FixedLenType::Money4, VarLenType::Money, 4), + (FixedLenType::Int8, VarLenType::Intn, 8), + ]; + + for (fixed, expected_ty, expected_len) in cases { + match fixed_to_var_len(fixed) { + Some(TypeInfo::VarLenSized(ctx)) => { + assert_eq!(ctx.r#type(), expected_ty, "{:?}", fixed); + assert_eq!(ctx.len(), expected_len, "{:?}", fixed); + } + other => panic!("unexpected result for {:?}: {:?}", fixed, other), + } + } + } + + #[test] + fn with_metadata_rewrites_fixed_len_columns() { + use crate::{BaseMetaDataColumn, ColumnFlag}; + use enumflags2::BitFlags; + + let metadata = vec![MetaDataColumn { + base: BaseMetaDataColumn { + flags: BitFlags::from(ColumnFlag::Nullable), + ty: TypeInfo::FixedLen(FixedLenType::Int4), + }, + col_name: Default::default(), + }]; + + let tvp = TypeInfoTvp::new("MyType", Vec::new()).with_metadata(metadata); + let columns = tvp.columns.as_ref().unwrap(); + assert!(matches!(columns[0].base.ty, TypeInfo::VarLenSized(_))); + } + + #[test] + fn encodes_metadata_and_row_data() { + use crate::{BaseMetaDataColumn, ColumnData, ColumnFlag, VarLenContext, VarLenType}; + use enumflags2::BitFlags; + + let metadata = vec![MetaDataColumn { + base: BaseMetaDataColumn { + flags: BitFlags::from(ColumnFlag::Nullable), + ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 4, None)), + }, + col_name: Default::default(), + }]; + + let rows = vec![vec![ColumnData::I32(Some(7))]]; + let tvp = TypeInfoTvp::new("dbo.MyType", rows).with_metadata(metadata); + + let mut buf = BytesMut::new(); + tvp.encode(&mut buf).unwrap(); + + // Ends with the TVP_END_TOKEN. + assert_eq!(*buf.last().unwrap(), 0); + // A single TVP_ROW_TOKEN (0x01) must appear before the row data. + assert!(buf.contains(&0x01u8)); + } } diff --git a/src/tds/collation.rs b/src/tds/collation.rs index cdc28c085..7df7dca44 100644 --- a/src/tds/collation.rs +++ b/src/tds/collation.rs @@ -3,8 +3,8 @@ //! directly from microsoft //! [2] is helpful to map CP1234 to the appropriate encoding //! -//! [1] https://github.com/Microsoft/mssql-jdbc/blob/eb14f63077c47ef1fc1c690deb8cfab602baeb85/src/main/java/com/microsoft/sqlserver/jdbc/SQLCollation.java -//! [2] https://github.com/lifthrasiir/rust-encoding/blob/496823171f15d9b9446b2ec3fb7765f22346256b/src/label.rs#L282 +//! [1] +//! [2] use encoding_rs::Encoding; use std::fmt; @@ -394,30 +394,415 @@ pub fn sortid_to_encoding(sort_id: u8) -> Option<&'static Encoding> { } } -/* TODO #[cfg(test)] mod tests { - use futures_state_stream::StateStream; - use tokio::executor::current_thread; - use crate::tests::new_connection; + use super::*; #[test] - fn select_nvarchar_collation_test() { - let c1 = new_connection(); - let query = c1.simple_query( - "select cast(cast(N'cześć' as nvarchar(5)) collate Polish_CI_AI as varchar(5))", - ); - let mut i = 0; - { - let future = query.for_each(|x| { - let val: &str = x.get(0); - assert_eq!(val, "cześć"); - i += 1; - Ok(()) - }); - current_thread::block_on_all(future).unwrap(); + fn accessors_split_info_and_sort_id() { + // info holds LCID in the low 16 bits plus flags/version in the high bits. + let collation = Collation::new(0x0020_0409, 52); + assert_eq!(collation.info(), 0x0020_0409); + assert_eq!(collation.lcid(), 0x0409); + assert_eq!(collation.sort_id(), 52); + } + + #[test] + fn encoding_from_lcid_when_sort_id_zero() { + // sort_id == 0 -> resolve via the LCID. + let collation = Collation::new(0x0409, 0); + let encoding = collation.encoding().expect("known LCID must resolve"); + assert_eq!(encoding, encoding_rs::WINDOWS_1252); + } + + #[test] + fn encoding_from_sort_id_when_present() { + // A non-zero sort_id takes precedence over the LCID. + let collation = Collation::new(0x0405, 80); + let encoding = collation.encoding().expect("known sort id must resolve"); + assert_eq!(encoding, encoding_rs::WINDOWS_1250); + } + + #[test] + fn encoding_unknown_lcid_errors() { + let collation = Collation::new(0xFFFF, 0); + let err = collation.encoding().unwrap_err(); + assert!(matches!(err, Error::Encoding(_))); + } + + #[test] + fn encoding_unknown_sort_id_errors() { + let collation = Collation::new(0x0409, 250); + assert!(collation.encoding().is_err()); + } + + #[test] + fn display_uses_encoding_name() { + let collation = Collation::new(0x0409, 0); + assert_eq!(format!("{}", collation), encoding_rs::WINDOWS_1252.name()); + } + + #[test] + fn display_falls_back_to_none_on_unknown() { + let collation = Collation::new(0xFFFF, 0); + assert_eq!(format!("{}", collation), "None"); + } + + #[test] + fn lcid_to_encoding_known_and_unknown() { + assert_eq!(lcid_to_encoding(0x0401), Some(encoding_rs::WINDOWS_1256)); + assert_eq!(lcid_to_encoding(0x0404), Some(encoding_rs::BIG5)); + assert_eq!(lcid_to_encoding(0x0411), Some(encoding_rs::SHIFT_JIS)); + assert_eq!(lcid_to_encoding(0x0412), Some(encoding_rs::EUC_KR)); + assert_eq!(lcid_to_encoding(0x0804), Some(encoding_rs::GB18030)); + assert_eq!(lcid_to_encoding(0x0439), Some(encoding_rs::UTF_16LE)); + assert_eq!(lcid_to_encoding(0x041e), Some(encoding_rs::WINDOWS_874)); + assert_eq!(lcid_to_encoding(0x0000), None); + } + + #[test] + fn sortid_to_encoding_known_and_unknown() { + assert_eq!(sortid_to_encoding(50), Some(encoding_rs::WINDOWS_1252)); + assert_eq!(sortid_to_encoding(80), Some(encoding_rs::WINDOWS_1250)); + assert_eq!(sortid_to_encoding(104), Some(encoding_rs::WINDOWS_1251)); + assert_eq!(sortid_to_encoding(112), Some(encoding_rs::WINDOWS_1253)); + assert_eq!(sortid_to_encoding(192), Some(encoding_rs::SHIFT_JIS)); + assert_eq!(sortid_to_encoding(194), Some(encoding_rs::EUC_KR)); + assert_eq!(sortid_to_encoding(196), Some(encoding_rs::BIG5)); + assert_eq!(sortid_to_encoding(198), Some(encoding_rs::GB18030)); + assert_eq!(sortid_to_encoding(204), Some(encoding_rs::WINDOWS_874)); + assert_eq!(sortid_to_encoding(0), None); + assert_eq!(sortid_to_encoding(255), None); + } + + #[test] + fn lcid_to_encoding_covers_all_documented_locales() { + let cases: &[(u16, &encoding_rs::Encoding)] = &[ + (0x0401, encoding_rs::WINDOWS_1256), + (0x0402, encoding_rs::WINDOWS_1251), + (0x0403, encoding_rs::WINDOWS_1252), + (0x0404, encoding_rs::BIG5), + (0x0c04, encoding_rs::BIG5), + (0x1404, encoding_rs::BIG5), + (0x0405, encoding_rs::WINDOWS_1250), + (0x0406, encoding_rs::WINDOWS_1252), + (0x0407, encoding_rs::WINDOWS_1252), + (0x0408, encoding_rs::WINDOWS_1253), + (0x0409, encoding_rs::WINDOWS_1252), + (0x040a, encoding_rs::WINDOWS_1252), + (0x040b, encoding_rs::WINDOWS_1252), + (0x040c, encoding_rs::WINDOWS_1252), + (0x040d, encoding_rs::WINDOWS_1255), + (0x040e, encoding_rs::WINDOWS_1250), + (0x040f, encoding_rs::WINDOWS_1252), + (0x0410, encoding_rs::WINDOWS_1252), + (0x0411, encoding_rs::SHIFT_JIS), + (0x0412, encoding_rs::EUC_KR), + (0x0413, encoding_rs::WINDOWS_1252), + (0x0414, encoding_rs::WINDOWS_1252), + (0x0415, encoding_rs::WINDOWS_1250), + (0x0416, encoding_rs::WINDOWS_1252), + (0x0417, encoding_rs::WINDOWS_1252), + (0x0418, encoding_rs::WINDOWS_1250), + (0x0419, encoding_rs::WINDOWS_1251), + (0x041a, encoding_rs::WINDOWS_1250), + (0x041b, encoding_rs::WINDOWS_1250), + (0x041c, encoding_rs::WINDOWS_1250), + (0x041d, encoding_rs::WINDOWS_1252), + (0x041e, encoding_rs::WINDOWS_874), + (0x041f, encoding_rs::WINDOWS_1254), + (0x0420, encoding_rs::WINDOWS_1256), + (0x0421, encoding_rs::WINDOWS_1252), + (0x0422, encoding_rs::WINDOWS_1251), + (0x0423, encoding_rs::WINDOWS_1251), + (0x0424, encoding_rs::WINDOWS_1250), + (0x0425, encoding_rs::WINDOWS_1257), + (0x0426, encoding_rs::WINDOWS_1257), + (0x0427, encoding_rs::WINDOWS_1257), + (0x0428, encoding_rs::WINDOWS_1251), + (0x0429, encoding_rs::WINDOWS_1256), + (0x042a, encoding_rs::WINDOWS_1258), + (0x042b, encoding_rs::WINDOWS_1252), + (0x042c, encoding_rs::WINDOWS_1254), + (0x042d, encoding_rs::WINDOWS_1252), + (0x042e, encoding_rs::WINDOWS_1252), + (0x042f, encoding_rs::WINDOWS_1251), + (0x0432, encoding_rs::WINDOWS_1252), + (0x0434, encoding_rs::WINDOWS_1252), + (0x0435, encoding_rs::WINDOWS_1252), + (0x0436, encoding_rs::WINDOWS_1252), + (0x0437, encoding_rs::WINDOWS_1252), + (0x0438, encoding_rs::WINDOWS_1252), + (0x0439, encoding_rs::UTF_16LE), + (0x043a, encoding_rs::UTF_16LE), + (0x043b, encoding_rs::WINDOWS_1252), + (0x043e, encoding_rs::WINDOWS_1252), + (0x043f, encoding_rs::WINDOWS_1251), + (0x0440, encoding_rs::WINDOWS_1251), + (0x0441, encoding_rs::WINDOWS_1252), + (0x0442, encoding_rs::WINDOWS_1250), + (0x0443, encoding_rs::WINDOWS_1254), + (0x0444, encoding_rs::WINDOWS_1251), + (0x0445, encoding_rs::UTF_16LE), + (0x0446, encoding_rs::UTF_16LE), + (0x0447, encoding_rs::UTF_16LE), + (0x0448, encoding_rs::UTF_16LE), + (0x0449, encoding_rs::UTF_16LE), + (0x044a, encoding_rs::UTF_16LE), + (0x044b, encoding_rs::UTF_16LE), + (0x044c, encoding_rs::UTF_16LE), + (0x044d, encoding_rs::UTF_16LE), + (0x044e, encoding_rs::UTF_16LE), + (0x044f, encoding_rs::UTF_16LE), + (0x0450, encoding_rs::WINDOWS_1251), + (0x0451, encoding_rs::UTF_16LE), + (0x0452, encoding_rs::WINDOWS_1252), + (0x0453, encoding_rs::UTF_16LE), + (0x0454, encoding_rs::UTF_16LE), + (0x0456, encoding_rs::WINDOWS_1252), + (0x0457, encoding_rs::UTF_16LE), + (0x045a, encoding_rs::UTF_16LE), + (0x045b, encoding_rs::UTF_16LE), + (0x045d, encoding_rs::WINDOWS_1252), + (0x045e, encoding_rs::WINDOWS_1252), + (0x0461, encoding_rs::UTF_16LE), + (0x0462, encoding_rs::WINDOWS_1252), + (0x0463, encoding_rs::UTF_16LE), + (0x0464, encoding_rs::WINDOWS_1252), + (0x0465, encoding_rs::UTF_16LE), + (0x0468, encoding_rs::WINDOWS_1252), + (0x046a, encoding_rs::WINDOWS_1252), + (0x046b, encoding_rs::WINDOWS_1252), + (0x046c, encoding_rs::WINDOWS_1252), + (0x046d, encoding_rs::WINDOWS_1251), + (0x046e, encoding_rs::WINDOWS_1252), + (0x046f, encoding_rs::WINDOWS_1252), + (0x0470, encoding_rs::WINDOWS_1252), + (0x0478, encoding_rs::WINDOWS_1252), + (0x047a, encoding_rs::WINDOWS_1252), + (0x047c, encoding_rs::WINDOWS_1252), + (0x047e, encoding_rs::WINDOWS_1252), + (0x0480, encoding_rs::WINDOWS_1256), + (0x0481, encoding_rs::UTF_16LE), + (0x0482, encoding_rs::WINDOWS_1252), + (0x0483, encoding_rs::WINDOWS_1252), + (0x0484, encoding_rs::WINDOWS_1252), + (0x0485, encoding_rs::WINDOWS_1251), + (0x0486, encoding_rs::WINDOWS_1252), + (0x0487, encoding_rs::WINDOWS_1252), + (0x0488, encoding_rs::WINDOWS_1252), + (0x048c, encoding_rs::WINDOWS_1256), + (0x0801, encoding_rs::WINDOWS_1256), + (0x0804, encoding_rs::GB18030), + (0x1004, encoding_rs::GB18030), + (0x0807, encoding_rs::WINDOWS_1252), + (0x0809, encoding_rs::WINDOWS_1252), + (0x080a, encoding_rs::WINDOWS_1252), + (0x080c, encoding_rs::WINDOWS_1252), + (0x0810, encoding_rs::WINDOWS_1252), + (0x0813, encoding_rs::WINDOWS_1252), + (0x0814, encoding_rs::WINDOWS_1252), + (0x0816, encoding_rs::WINDOWS_1252), + (0x081a, encoding_rs::WINDOWS_1250), + (0x081d, encoding_rs::WINDOWS_1252), + (0x0827, encoding_rs::WINDOWS_1257), + (0x082c, encoding_rs::WINDOWS_1251), + (0x082e, encoding_rs::WINDOWS_1252), + (0x083b, encoding_rs::WINDOWS_1252), + (0x083c, encoding_rs::WINDOWS_1252), + (0x083e, encoding_rs::WINDOWS_1252), + (0x0843, encoding_rs::WINDOWS_1251), + (0x0845, encoding_rs::UTF_16LE), + (0x0850, encoding_rs::WINDOWS_1251), + (0x085d, encoding_rs::WINDOWS_1252), + (0x085f, encoding_rs::WINDOWS_1252), + (0x086b, encoding_rs::WINDOWS_1252), + (0x0c01, encoding_rs::WINDOWS_1256), + (0x0c07, encoding_rs::WINDOWS_1252), + (0x0c09, encoding_rs::WINDOWS_1252), + (0x0c0a, encoding_rs::WINDOWS_1252), + (0x0c0c, encoding_rs::WINDOWS_1252), + (0x0c1a, encoding_rs::WINDOWS_1251), + (0x0c3b, encoding_rs::WINDOWS_1252), + (0x0c6b, encoding_rs::WINDOWS_1252), + (0x1001, encoding_rs::WINDOWS_1256), + (0x1007, encoding_rs::WINDOWS_1252), + (0x1009, encoding_rs::WINDOWS_1252), + (0x100a, encoding_rs::WINDOWS_1252), + (0x100c, encoding_rs::WINDOWS_1252), + (0x101a, encoding_rs::WINDOWS_1250), + (0x103b, encoding_rs::WINDOWS_1252), + (0x1401, encoding_rs::WINDOWS_1256), + (0x1407, encoding_rs::WINDOWS_1252), + (0x1409, encoding_rs::WINDOWS_1252), + (0x140a, encoding_rs::WINDOWS_1252), + (0x140c, encoding_rs::WINDOWS_1252), + (0x141a, encoding_rs::WINDOWS_1250), + (0x143b, encoding_rs::WINDOWS_1252), + (0x1801, encoding_rs::WINDOWS_1256), + (0x1809, encoding_rs::WINDOWS_1252), + (0x180a, encoding_rs::WINDOWS_1252), + (0x180c, encoding_rs::WINDOWS_1252), + (0x181a, encoding_rs::WINDOWS_1250), + (0x183b, encoding_rs::WINDOWS_1252), + (0x1c01, encoding_rs::WINDOWS_1256), + (0x1c09, encoding_rs::WINDOWS_1252), + (0x1c0a, encoding_rs::WINDOWS_1252), + (0x1c1a, encoding_rs::WINDOWS_1251), + (0x1c3b, encoding_rs::WINDOWS_1252), + (0x2001, encoding_rs::WINDOWS_1256), + (0x2009, encoding_rs::WINDOWS_1252), + (0x200a, encoding_rs::WINDOWS_1252), + (0x201a, encoding_rs::WINDOWS_1251), + (0x203b, encoding_rs::WINDOWS_1252), + (0x2401, encoding_rs::WINDOWS_1256), + (0x2409, encoding_rs::WINDOWS_1252), + (0x240a, encoding_rs::WINDOWS_1252), + (0x243b, encoding_rs::WINDOWS_1252), + (0x2801, encoding_rs::WINDOWS_1256), + (0x2809, encoding_rs::WINDOWS_1252), + (0x280a, encoding_rs::WINDOWS_1252), + (0x2c01, encoding_rs::WINDOWS_1256), + (0x2c09, encoding_rs::WINDOWS_1252), + (0x2c0a, encoding_rs::WINDOWS_1252), + (0x3001, encoding_rs::WINDOWS_1256), + (0x3009, encoding_rs::WINDOWS_1252), + (0x300a, encoding_rs::WINDOWS_1252), + (0x3401, encoding_rs::WINDOWS_1256), + (0x3409, encoding_rs::WINDOWS_1252), + (0x340a, encoding_rs::WINDOWS_1252), + (0x3801, encoding_rs::WINDOWS_1256), + (0x380a, encoding_rs::WINDOWS_1252), + (0x3c01, encoding_rs::WINDOWS_1256), + (0x3c0a, encoding_rs::WINDOWS_1252), + (0x4001, encoding_rs::WINDOWS_1256), + (0x4009, encoding_rs::WINDOWS_1252), + (0x400a, encoding_rs::WINDOWS_1252), + (0x4409, encoding_rs::WINDOWS_1252), + (0x440a, encoding_rs::WINDOWS_1252), + (0x4809, encoding_rs::WINDOWS_1252), + (0x480a, encoding_rs::WINDOWS_1252), + (0x4c0a, encoding_rs::WINDOWS_1252), + (0x500a, encoding_rs::WINDOWS_1252), + (0x540a, encoding_rs::WINDOWS_1252), + ]; + + for (locale, expected) in cases { + assert_eq!( + lcid_to_encoding(*locale), + Some(*expected), + "locale {:#06x}", + locale + ); } - assert_eq!(i, 1); + } + + #[test] + fn sortid_to_encoding_covers_all_documented_sort_ids() { + let cases: &[(u8, &encoding_rs::Encoding)] = &[ + (50, encoding_rs::WINDOWS_1252), + (51, encoding_rs::WINDOWS_1252), + (52, encoding_rs::WINDOWS_1252), + (53, encoding_rs::WINDOWS_1252), + (54, encoding_rs::WINDOWS_1252), + (71, encoding_rs::WINDOWS_1252), + (72, encoding_rs::WINDOWS_1252), + (73, encoding_rs::WINDOWS_1252), + (74, encoding_rs::WINDOWS_1252), + (75, encoding_rs::WINDOWS_1252), + (80, encoding_rs::WINDOWS_1250), + (81, encoding_rs::WINDOWS_1250), + (82, encoding_rs::WINDOWS_1250), + (83, encoding_rs::WINDOWS_1250), + (84, encoding_rs::WINDOWS_1250), + (85, encoding_rs::WINDOWS_1250), + (86, encoding_rs::WINDOWS_1250), + (87, encoding_rs::WINDOWS_1250), + (88, encoding_rs::WINDOWS_1250), + (89, encoding_rs::WINDOWS_1250), + (90, encoding_rs::WINDOWS_1250), + (91, encoding_rs::WINDOWS_1250), + (92, encoding_rs::WINDOWS_1250), + (93, encoding_rs::WINDOWS_1250), + (94, encoding_rs::WINDOWS_1250), + (95, encoding_rs::WINDOWS_1250), + (96, encoding_rs::WINDOWS_1250), + (97, encoding_rs::WINDOWS_1250), + (98, encoding_rs::WINDOWS_1250), + (104, encoding_rs::WINDOWS_1251), + (105, encoding_rs::WINDOWS_1251), + (106, encoding_rs::WINDOWS_1251), + (107, encoding_rs::WINDOWS_1251), + (108, encoding_rs::WINDOWS_1251), + (112, encoding_rs::WINDOWS_1253), + (113, encoding_rs::WINDOWS_1253), + (114, encoding_rs::WINDOWS_1253), + (120, encoding_rs::WINDOWS_1253), + (121, encoding_rs::WINDOWS_1253), + (122, encoding_rs::WINDOWS_1253), + (124, encoding_rs::WINDOWS_1253), + (128, encoding_rs::WINDOWS_1254), + (129, encoding_rs::WINDOWS_1254), + (130, encoding_rs::WINDOWS_1254), + (136, encoding_rs::WINDOWS_1255), + (137, encoding_rs::WINDOWS_1255), + (138, encoding_rs::WINDOWS_1255), + (144, encoding_rs::WINDOWS_1256), + (145, encoding_rs::WINDOWS_1256), + (146, encoding_rs::WINDOWS_1256), + (152, encoding_rs::WINDOWS_1257), + (153, encoding_rs::WINDOWS_1257), + (154, encoding_rs::WINDOWS_1257), + (155, encoding_rs::WINDOWS_1257), + (156, encoding_rs::WINDOWS_1257), + (157, encoding_rs::WINDOWS_1257), + (158, encoding_rs::WINDOWS_1257), + (159, encoding_rs::WINDOWS_1257), + (160, encoding_rs::WINDOWS_1257), + (183, encoding_rs::WINDOWS_1252), + (184, encoding_rs::WINDOWS_1252), + (185, encoding_rs::WINDOWS_1252), + (186, encoding_rs::WINDOWS_1252), + (192, encoding_rs::SHIFT_JIS), + (193, encoding_rs::SHIFT_JIS), + (200, encoding_rs::SHIFT_JIS), + (194, encoding_rs::EUC_KR), + (195, encoding_rs::EUC_KR), + (196, encoding_rs::BIG5), + (197, encoding_rs::BIG5), + (202, encoding_rs::BIG5), + (198, encoding_rs::GB18030), + (199, encoding_rs::GB18030), + (203, encoding_rs::GB18030), + (201, encoding_rs::BIG5), + (204, encoding_rs::WINDOWS_874), + (205, encoding_rs::WINDOWS_874), + (206, encoding_rs::WINDOWS_874), + (210, encoding_rs::WINDOWS_1252), + (211, encoding_rs::WINDOWS_1252), + (212, encoding_rs::WINDOWS_1252), + (213, encoding_rs::WINDOWS_1252), + (214, encoding_rs::WINDOWS_1252), + (215, encoding_rs::WINDOWS_1252), + (216, encoding_rs::WINDOWS_1252), + (217, encoding_rs::WINDOWS_1252), + ]; + + for (sort_id, expected) in cases { + assert_eq!( + sortid_to_encoding(*sort_id), + Some(*expected), + "sort_id {}", + sort_id + ); + } + } + + #[test] + fn derives_eq_and_copy() { + let a = Collation::new(0x0409, 0); + let b = a; + assert_eq!(a, b); + assert_ne!(a, Collation::new(0x0409, 1)); } } -*/ diff --git a/src/tds/context.rs b/src/tds/context.rs index a9ebcc03a..a1f02d597 100644 --- a/src/tds/context.rs +++ b/src/tds/context.rs @@ -70,6 +70,13 @@ impl Context { self.transaction_desc = desc; } + /// Overrides the negotiated protocol version. Used by tests to exercise + /// version-dependent decode paths (e.g. the pre-2005 4-byte DONE rowcount). + #[cfg(test)] + pub(crate) fn set_version(&mut self, version: FeatureLevel) { + self.version = version; + } + pub fn version(&self) -> FeatureLevel { self.version } @@ -86,3 +93,101 @@ impl Context { self.spn.as_deref().unwrap_or("") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_has_expected_defaults() { + let ctx = Context::new(); + assert_eq!(ctx.packet_size(), 4096); + assert_eq!(ctx.transaction_descriptor(), [0; 8]); + assert!(ctx.last_meta().is_none()); + assert!(ctx.alt_meta(0).is_none()); + } + + #[test] + fn next_packet_id_increments_and_wraps() { + let mut ctx = Context::new(); + assert_eq!(ctx.next_packet_id(), 0); + assert_eq!(ctx.next_packet_id(), 1); + assert_eq!(ctx.next_packet_id(), 2); + + // Force a wraparound to make sure it doesn't panic on overflow. + for _ in 0..252 { + ctx.next_packet_id(); + } + assert_eq!(ctx.next_packet_id(), 255); + assert_eq!(ctx.next_packet_id(), 0); + } + + #[test] + fn set_and_get_packet_size() { + let mut ctx = Context::new(); + ctx.set_packet_size(8192); + assert_eq!(ctx.packet_size(), 8192); + } + + #[test] + fn set_and_get_transaction_descriptor() { + let mut ctx = Context::new(); + let desc = [1, 2, 3, 4, 5, 6, 7, 8]; + ctx.set_transaction_descriptor(desc); + assert_eq!(ctx.transaction_descriptor(), desc); + } + + #[test] + fn set_and_get_last_meta() { + let mut ctx = Context::new(); + let meta = Arc::new(TokenColMetaData { columns: vec![] }); + ctx.set_last_meta(meta.clone()); + + let got = ctx.last_meta().unwrap(); + assert_eq!(got.columns.len(), meta.columns.len()); + } + + #[test] + fn set_and_get_alt_meta_by_id() { + let mut ctx = Context::new(); + let meta = Arc::new(TokenAltMetaData { + id: 7, + by_columns: vec![1, 2], + columns: vec![], + }); + ctx.set_alt_meta(meta.clone()); + + let got = ctx.alt_meta(7).unwrap(); + assert_eq!(got.id, 7); + assert_eq!(got.by_columns, vec![1, 2]); + + // A different id should still be absent. + assert!(ctx.alt_meta(8).is_none()); + } + + #[test] + fn version_defaults_to_sql_server_n() { + let ctx = Context::new(); + assert_eq!(ctx.version(), FeatureLevel::SqlServerN); + } + + #[test] + fn set_spn_formats_service_principal_name() { + let mut ctx = Context::new(); + ctx.set_spn("dbhost", 1433); + + #[cfg(any( + windows, + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")) + ))] + assert_eq!(ctx.spn(), "MSSQLSvc/dbhost:1433"); + + // On platforms without an spn() accessor, at least make sure setting + // it doesn't panic. + #[cfg(not(any( + windows, + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")) + )))] + let _ = ctx; + } +} diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index caceb9deb..ba38d3656 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -110,10 +110,10 @@ impl Numeric { _ => unreachable!(), }; - // swap high&low for big endian - #[cfg(target_endian = "big")] - let (low_part, high_part) = (high_part, low_part); - + // `byteorder::LittleEndian` already yields the correct host-native + // integer regardless of target endianness, so `low_part`/`high_part` + // need no further swapping (a previous `cfg(target_endian = "big")` + // swap here corrupted large decimals on big-endian hosts). let high_part = high_part * (u64::MAX as u128 + 1); low_part + high_part } @@ -144,7 +144,17 @@ impl Numeric { for item in &mut bytes { *item = src.read_u8().await?; } - decode_d128(&bytes) as i128 * sign + let magnitude = decode_d128(&bytes); + // A legal `decimal(38, s)` magnitude is < 10^38 < i128::MAX, + // so any 16-byte magnitude that does not fit in i128 is + // malformed. Reject it rather than letting `as i128` wrap to + // a negative value (and `i128::MIN * -1` overflow-panic). + if magnitude > i128::MAX as u128 { + return Err(Error::Protocol( + "decimal/numeric: magnitude exceeds the representable range".into(), + )); + } + magnitude as i128 * sign } x => { return Err(Error::Protocol( @@ -160,7 +170,9 @@ impl Numeric { impl Encode for Numeric { fn encode(self, dst: &mut BytesMut) -> crate::Result<()> { - dst.put_u8(self.len()); + // `len()` recomputes `precision()` via a division loop; compute it once. + let len = self.len(); + dst.put_u8(len); if self.value < 0 { dst.put_u8(0); @@ -170,7 +182,7 @@ impl Encode for Numeric { let value = self.value().abs(); - match self.len() { + match len { 5 => dst.put_u32_le(value as u32), 9 => dst.put_u64_le(value as u64), 13 => { @@ -376,6 +388,94 @@ mod tests { ); } + #[test] + fn numeric_eq_normalizes_across_a_scale_gap() { + // 1.23 at scale 5 (123000) equals 1.23 at scale 2 (123). A scale gap of + // 3 is chosen so the `self.scale - other.scale` exponent (3) differs from + // both `+` (7) and `/` (1) — pinning the subtraction — and the + // `10^gap * v` multiply differs from `+`/`/`. Both comparison directions + // exercise the Greater and Less arms. + let wide = Numeric { + value: 123_000, + scale: 5, + }; + let narrow = Numeric { + value: 123, + scale: 2, + }; + assert_eq!(wide, narrow); // Greater arm (self.scale > other.scale) + assert_eq!(narrow, wide); // Less arm + assert!( + narrow + != Numeric { + value: 124, + scale: 2 + } + ); + } + + #[test] + fn encode_byte_layout_matches_length_bucket() { + // The encoder writes 1 length byte + 1 sign byte + (len-1) magnitude + // bytes. This pins the per-length arms (deleting the 9- or 13-byte arm + // would change the byte count) and the sign byte for zero. + for value in [1i128, 10i128.pow(12), 10i128.pow(20), 10i128.pow(30)] { + let n = Numeric::new_with_scale(value, 0); + let expected = n.len() as usize + 1; + let mut buf = BytesMut::new(); + n.encode(&mut buf).unwrap(); + assert_eq!(buf.len(), expected, "byte count for {value}"); + } + + // Zero is encoded as positive (sign byte 1), not negative. + let mut zero = BytesMut::new(); + Numeric::new_with_scale(0, 0).encode(&mut zero).unwrap(); + assert_eq!(zero[1], 1, "zero must carry the positive sign byte"); + } + + #[tokio::test] + async fn decode_d128_keeps_high_and_low_words() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + // A magnitude whose high bytes are all non-zero: if decode_d128 wrongly + // short-circuited on "all high bytes non-zero" it would drop the high + // word and mis-decode. Positive (high byte 0x01 < i128::MAX high bit). + let value = 0x0101_0101_0101_0101_0101_0101_0101_0101i128; + let n = Numeric::new_with_scale(value, 0); + let mut buf = BytesMut::new(); + n.encode(&mut buf).unwrap(); + let decoded = Numeric::decode(&mut buf.into_sql_read_bytes(), 0) + .await + .unwrap() + .unwrap(); + assert_eq!(decoded.value(), value); + } + + #[tokio::test] + async fn decode_accepts_magnitude_at_i128_max_but_rejects_beyond() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + // 17-byte form: len, sign(1 = positive), then 16 magnitude bytes. + let mut at_max = BytesMut::new(); + at_max.put_u8(17); + at_max.put_u8(1); + at_max.put_i128_le(i128::MAX); // magnitude exactly i128::MAX + let decoded = Numeric::decode(&mut at_max.into_sql_read_bytes(), 0) + .await + .expect("i128::MAX magnitude is representable") + .unwrap(); + assert_eq!(decoded.value(), i128::MAX); + + // One past i128::MAX (high bit set) must be rejected, not wrapped. + let mut beyond = BytesMut::new(); + beyond.put_u8(17); + beyond.put_u8(1); + beyond.put_u128_le((i128::MAX as u128) + 1); + assert!(Numeric::decode(&mut beyond.into_sql_read_bytes(), 0) + .await + .is_err()); + } + #[test] fn numeric_to_f64() { assert_eq!(f64::from(Numeric::new_with_scale(57705, 2)), 577.05); @@ -424,6 +524,161 @@ mod tests { assert_eq!(5, n.precision()); } + #[test] + fn new_with_scale_accessors() { + let n = Numeric::new_with_scale(12345, 3); + assert_eq!(n.value(), 12345); + assert_eq!(n.scale(), 3); + assert_eq!(n.int_part(), 12); + assert_eq!(n.dec_part(), 345); + } + + #[test] + fn new_with_scale_allows_max_scale() { + // decimal(38, 38) is valid in SQL Server, so scale 38 must be accepted. + assert_eq!(Numeric::new_with_scale(1, 38).scale(), 38); + } + + #[test] + #[should_panic] + fn new_with_scale_panics_on_too_large_scale() { + Numeric::new_with_scale(1, 39); + } + + #[test] + fn precision_with_zero_int_part() { + // int_part == 0 -> precision is 1 + scale. + let n = Numeric::new_with_scale(5, 2); + assert_eq!(n.int_part(), 0); + assert_eq!(n.precision(), 3); + } + + #[test] + fn precision_scaling_by_length_buckets() { + assert_eq!(Numeric::new_with_scale(1, 0).len(), 5); + assert_eq!(Numeric::new_with_scale(1_000_000_000, 0).len(), 9); + assert_eq!(Numeric::new_with_scale(10i128.pow(19), 0).len(), 13); + assert_eq!(Numeric::new_with_scale(10i128.pow(28), 0).len(), 17); + } + + #[test] + fn display_and_debug() { + let n = Numeric::new_with_scale(57705, 2); + assert_eq!(format!("{:?}", n), "577.05"); + assert_eq!(format!("{}", n), "577.05"); + + // Negative values format with a single leading sign and an unsigned + // fractional part (see #390). + let n = Numeric::new_with_scale(-57705, 3); + assert_eq!(format!("{}", n), "-57.705"); + + // Zero-padded fractional part for small decimals. + let n = Numeric::new_with_scale(102, 4); + assert_eq!(format!("{}", n), "0.0102"); + } + + #[test] + fn from_numeric_conversions() { + let n = Numeric::new_with_scale(57705, 2); + assert_eq!(i128::from(n), 577); + assert_eq!(u128::from(n), 577); + assert!((f64::from(n) - 577.05).abs() < f64::EPSILON); + } + + #[test] + fn eq_across_scales_negative() { + assert_eq!( + Numeric::new_with_scale(-100501, 2), + Numeric::new_with_scale(-1005010, 3), + ); + } + + async fn round_trip(value: i128, scale: u8) { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + let n = Numeric::new_with_scale(value, scale); + let mut buf = BytesMut::new(); + n.encode(&mut buf).expect("encode must succeed"); + + let decoded = Numeric::decode(&mut buf.into_sql_read_bytes(), scale) + .await + .expect("decode must succeed") + .expect("value must be present"); + + assert_eq!(decoded, n); + assert_eq!(decoded.value(), value); + } + + #[tokio::test] + async fn encode_decode_round_trip() { + round_trip(0, 0).await; // len 5 + round_trip(42, 0).await; // len 5 + round_trip(-42, 2).await; // negative, len 5 + round_trip(10i128.pow(12), 0).await; // len 9 + round_trip(10i128.pow(20), 0).await; // len 13 + round_trip(-(10i128.pow(20)), 3).await; // negative, len 13 + round_trip(10i128.pow(30), 0).await; // len 17 + } + + #[tokio::test] + async fn decode_zero_length_is_none() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + let mut buf = BytesMut::new(); + buf.put_u8(0); + + let decoded = Numeric::decode(&mut buf.into_sql_read_bytes(), 0) + .await + .expect("decode must succeed"); + + assert!(decoded.is_none()); + } + + #[tokio::test] + async fn decode_rejects_len17_magnitude_over_i128_max() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + // len = 17, sign = 1 (positive), magnitude = 2^127 (byte[15] = 0x80), + // which exceeds i128::MAX. Must return a protocol error rather than + // wrapping to a negative value (or panicking on i128::MIN * -1). + let mut buf = BytesMut::new(); + buf.put_u8(17); + buf.put_u8(1); + let mut mag = [0u8; 16]; + mag[15] = 0x80; + buf.extend_from_slice(&mag); + + let err = Numeric::decode(&mut buf.into_sql_read_bytes(), 0) + .await + .expect_err("out-of-range magnitude must error"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[tokio::test] + async fn decode_rejects_invalid_sign_and_length() { + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + // Invalid sign byte (2 is neither 0 nor 1). + let mut buf = BytesMut::new(); + buf.put_u8(5); + buf.put_u8(2); + buf.put_u32_le(1); + let err = Numeric::decode(&mut buf.into_sql_read_bytes(), 0) + .await + .expect_err("invalid sign must error"); + assert!(matches!(err, Error::Protocol(_))); + + // Invalid length byte (6 is not one of 0/5/9/13/17). + let mut buf = BytesMut::new(); + buf.put_u8(6); + buf.put_u8(1); + buf.extend_from_slice(&[0u8; 4]); + let err = Numeric::decode(&mut buf.into_sql_read_bytes(), 0) + .await + .expect_err("invalid length must error"); + assert!(matches!(err, Error::Protocol(_))); + } + #[test] #[cfg(feature = "bigdecimal")] fn no_overflowing_pow() { diff --git a/src/tds/stream/command.rs b/src/tds/stream/command.rs index 160b7cf34..77b35efd0 100644 --- a/src/tds/stream/command.rs +++ b/src/tds/stream/command.rs @@ -299,8 +299,13 @@ impl<'a> Stream for CommandStream<'a> { Poll::Ready(Some(Ok(query_item))) } ReceivedToken::Row(data) => { - let columns = this.columns.as_ref().unwrap().clone(); - let result_index = this.result_set_index.unwrap(); + let Some(columns) = this.columns.as_ref() else { + return Poll::Ready(Some(Err(crate::Error::Protocol( + "ROW token arrived before any column metadata".into(), + )))); + }; + let columns = columns.clone(); + let result_index = this.result_set_index.unwrap_or(0); let row = Row { columns, diff --git a/src/tds/stream/query.rs b/src/tds/stream/query.rs index e8647c767..88c451e89 100644 --- a/src/tds/stream/query.rs +++ b/src/tds/stream/query.rs @@ -375,8 +375,13 @@ impl<'a> Stream for QueryStream<'a> { return Poll::Ready(Some(Ok(query_item))); } ReceivedToken::Row(data) => { - let columns = this.columns.as_ref().unwrap().clone(); - let result_index = this.result_set_index.unwrap(); + let Some(columns) = this.columns.as_ref() else { + return Poll::Ready(Some(Err(crate::Error::Protocol( + "ROW token arrived before any column metadata".into(), + )))); + }; + let columns = columns.clone(); + let result_index = this.result_set_index.unwrap_or(0); let row = Row { columns, diff --git a/src/tds/time.rs b/src/tds/time.rs index fb87718ef..2a4c0776b 100644 --- a/src/tds/time.rs +++ b/src/tds/time.rs @@ -442,3 +442,191 @@ impl Encode for DateTimeOffset { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; + + #[test] + fn datetime_accessors() { + let dt = DateTime::new(-100, 12345); + assert_eq!(dt.days(), -100); + assert_eq!(dt.seconds_fragments(), 12345); + } + + #[tokio::test] + async fn datetime_round_trip_including_pre_1900() { + for dt in [ + DateTime::new(0, 0), + DateTime::new(200, 3000), + DateTime::new(-53690, 25920000), + ] { + let mut buf = BytesMut::new(); + dt.encode(&mut buf).unwrap(); + let decoded = DateTime::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert_eq!(decoded, dt); + } + } + + #[test] + fn smalldatetime_accessors() { + let dt = SmallDateTime::new(100, 200); + assert_eq!(dt.days(), 100); + assert_eq!(dt.seconds_fragments(), 200); + } + + #[tokio::test] + async fn smalldatetime_round_trip() { + let dt = SmallDateTime::new(65535, 1439); + let mut buf = BytesMut::new(); + dt.encode(&mut buf).unwrap(); + let decoded = SmallDateTime::decode(&mut buf.into_sql_read_bytes()) + .await + .unwrap(); + assert_eq!(decoded, dt); + } + + #[cfg(feature = "tds73")] + #[test] + fn date_accessor_and_new() { + let date = Date::new(730119); + assert_eq!(date.days(), 730119); + } + + #[cfg(feature = "tds73")] + #[test] + #[should_panic] + fn date_new_panics_on_overflow() { + // Anything not representable in three bytes must panic. + Date::new(0x0100_0000); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn date_round_trip() { + for days in [0u32, 1, 730119, 0x00ff_ffff] { + let date = Date::new(days); + let mut buf = BytesMut::new(); + date.encode(&mut buf).unwrap(); + assert_eq!(buf.len(), 3); + let decoded = Date::decode(&mut buf.into_sql_read_bytes()).await.unwrap(); + assert_eq!(decoded, date); + } + } + + #[cfg(feature = "tds73")] + #[test] + fn time_accessors_and_len() { + let time = Time::new(1234, 5); + assert_eq!(time.increments(), 1234); + assert_eq!(time.scale(), 5); + assert_eq!(time.len().unwrap(), 5); + + assert_eq!(Time::new(0, 0).len().unwrap(), 3); + assert_eq!(Time::new(0, 3).len().unwrap(), 4); + assert!(Time::new(0, 8).len().is_err()); + } + + #[cfg(feature = "tds73")] + #[test] + fn time_partial_eq_across_scales() { + // 1 second expressed at two different scales must compare equal. + assert_eq!(Time::new(100, 2), Time::new(10_000_000, 7)); + assert_ne!(Time::new(100, 2), Time::new(200, 2)); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn time_round_trip_all_len_buckets() { + for (increments, scale) in [(255u64, 2u8), (65535, 4), (16_777_215, 7)] { + let time = Time::new(increments, scale); + let rlen = time.len().unwrap(); + let mut buf = BytesMut::new(); + time.encode(&mut buf).unwrap(); + let decoded = Time::decode( + &mut buf.into_sql_read_bytes(), + scale as usize, + rlen as usize, + ) + .await + .unwrap(); + assert_eq!(decoded, time); + } + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn time_round_trip_high_bytes_set() { + // Values whose most-significant byte (the byte handled by the + // `lo << 16` / `lo << 32` shift in `decode` and the `>> 16` / `>> 32` + // shift in `encode`) is non-zero. This distinguishes: + // * decode `<< N` from `>> N` (the latter zeroes an `u8`), and + // * encode `>> N` from `<< N` (the latter zeroes the byte written). + // The 16-bit / 32-bit low halves and the shifted high byte occupy + // disjoint bit ranges, so `|` vs `^` cannot be distinguished here. + for (increments, scale) in [(0x00FF_1234u64, 2u8), (0x00AB_1234_5678u64, 7)] { + let time = Time::new(increments, scale); + let rlen = time.len().unwrap(); + + let mut buf = BytesMut::new(); + time.encode(&mut buf).unwrap(); + + let decoded = Time::decode( + &mut buf.into_sql_read_bytes(), + scale as usize, + rlen as usize, + ) + .await + .unwrap(); + + assert_eq!(decoded, time); + assert_eq!(decoded.increments(), increments); + } + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn time_decode_invalid_length_errors() { + let mut buf = BytesMut::new(); + buf.put_u8(0); + // scale/length combination not one of the accepted pairs. + let err = Time::decode(&mut buf.into_sql_read_bytes(), 0, 4).await; + assert!(err.is_err()); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn datetime2_round_trip_and_accessors() { + let dt2 = DateTime2::new(Date::new(730119), Time::new(222, 7)); + assert_eq!(dt2.date(), Date::new(730119)); + assert_eq!(dt2.time(), Time::new(222, 7)); + + let rlen = dt2.time().len().unwrap(); + let mut buf = BytesMut::new(); + dt2.encode(&mut buf).unwrap(); + let decoded = DateTime2::decode(&mut buf.into_sql_read_bytes(), 7, rlen as usize) + .await + .unwrap(); + assert_eq!(decoded, dt2); + } + + #[cfg(feature = "tds73")] + #[tokio::test] + async fn datetimeoffset_round_trip_and_accessors() { + let dt2 = DateTime2::new(Date::new(730119), Time::new(222, 7)); + let dto = DateTimeOffset::new(dt2, -120); + assert_eq!(dto.datetime2(), dt2); + assert_eq!(dto.offset(), -120); + + let rlen = dto.datetime2().time().len().unwrap(); + let mut buf = BytesMut::new(); + dto.encode(&mut buf).unwrap(); + let decoded = DateTimeOffset::decode(&mut buf.into_sql_read_bytes(), 7, rlen) + .await + .unwrap(); + assert_eq!(decoded, dto); + } +} diff --git a/src/tds/time/chrono.rs b/src/tds/time/chrono.rs index 4be603a4c..3c583dfe0 100644 --- a/src/tds/time/chrono.rs +++ b/src/tds/time/chrono.rs @@ -14,12 +14,30 @@ use crate::tds::codec::ColumnData; #[cfg_attr(docsrs, doc(cfg(feature = "tds73")))] pub use chrono::offset::{FixedOffset, Utc}; pub use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime}; -#[cfg(feature = "tds73")] -use std::ops::Sub; #[inline] fn from_days(days: i64, start_year: i32) -> NaiveDate { - NaiveDate::from_ymd_opt(start_year, 1, 1).unwrap() + chrono::Duration::days(days) + // `days` derives from untrusted server bytes; clamp to the representable + // range instead of panicking on overflow. + let base = NaiveDate::from_ymd_opt(start_year, 1, 1).unwrap(); + base.checked_add_signed(chrono::Duration::days(days)) + .unwrap_or(if days < 0 { + NaiveDate::MIN + } else { + NaiveDate::MAX + }) +} + +/// Convert a server-supplied fractional-seconds `increments` at the given +/// `scale` into nanoseconds without panicking (`scale > 9` would underflow +/// `9 - scale`; a large `increments` would overflow the multiply). +#[inline] +#[cfg(feature = "tds73")] +fn nanos_from_increments(increments: u64, scale: u8) -> i64 { + let pow = 9u32.saturating_sub(scale as u32); + increments + .saturating_mul(10u64.saturating_pow(pow)) + .min(i64::MAX as u64) as i64 } #[inline] @@ -59,7 +77,7 @@ from_sql!( )), ColumnData::DateTime2(ref dt) => dt.map(|dt| NaiveDateTime::new( from_days(dt.date.days() as i64, 1), - NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(dt.time.increments as i64 * 10i64.pow(9 - dt.time.scale as u32)) + NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(nanos_from_increments(dt.time.increments, dt.time.scale)) )), ColumnData::DateTime(ref dt) => dt.map(|dt| NaiveDateTime::new( from_days(dt.days as i64, 1900), @@ -67,7 +85,7 @@ from_sql!( )); NaiveTime: ColumnData::Time(ref time) => time.map(|time| { - let ns = time.increments as i64 * 10i64.pow(9 - time.scale as u32); + let ns = nanos_from_increments(time.increments, time.scale); NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(ns) }); NaiveDate: @@ -75,17 +93,18 @@ from_sql!( chrono::DateTime: ColumnData::DateTimeOffset(ref dto) => dto.map(|dto| { let date = from_days(dto.datetime2.date.days() as i64, 1); - let ns = dto.datetime2.time.increments as i64 * 10i64.pow(9 - dto.datetime2.time.scale as u32); + let ns = nanos_from_increments(dto.datetime2.time.increments, dto.datetime2.time.scale); let time = NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(ns); let offset = chrono::Duration::minutes(dto.offset as i64); - let naive = NaiveDateTime::new(date, time).sub(offset); + let base = NaiveDateTime::new(date, time); + let naive = base.checked_sub_signed(offset).unwrap_or(base); chrono::DateTime::from_naive_utc_and_offset(naive, Utc) }), ColumnData::DateTime2(ref dt2) => dt2.map(|dt2| { let date = from_days(dt2.date.days() as i64, 1); - let ns = dt2.time.increments as i64 * 10i64.pow(9 - dt2.time.scale as u32); + let ns = nanos_from_increments(dt2.time.increments, dt2.time.scale); let time = NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(ns); let naive = NaiveDateTime::new(date, time); @@ -93,10 +112,10 @@ from_sql!( }); chrono::DateTime: ColumnData::DateTimeOffset(ref dto) => dto.map(|dto| { let date = from_days(dto.datetime2.date.days() as i64, 1); - let ns = dto.datetime2.time.increments as i64 * 10i64.pow(9 - dto.datetime2.time.scale as u32); + let ns = nanos_from_increments(dto.datetime2.time.increments, dto.datetime2.time.scale); let time = NaiveTime::from_hms_opt(0,0,0).unwrap() + chrono::Duration::nanoseconds(ns); - let offset = FixedOffset::east_opt((dto.offset as i32) * 60).unwrap(); + let offset = FixedOffset::east_opt((dto.offset as i32) * 60).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()); let naive = NaiveDateTime::new(date, time); chrono::DateTime::from_naive_utc_and_offset(naive, offset) @@ -241,3 +260,160 @@ from_sql!( from_sec_fragments(dt.seconds_fragments as i64) )) ); + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FromSql, IntoSql}; + + #[test] + fn from_days_clamps_on_overflow() { + // A day offset far outside the representable `NaiveDate` range forces + // the `checked_add_signed` fallback. Positive overflow must clamp to + // MAX, negative overflow to MIN. This pins the `days < 0` sign test + // (distinguishing it from `==` and `>`). + assert_eq!(from_days(200_000_000, 1), NaiveDate::MAX); + assert_eq!(from_days(-200_000_000, 1), NaiveDate::MIN); + } + + #[test] + fn from_sec_fragments_converts() { + // 300 sec-fragments (1/300 s units) == exactly one second. + assert_eq!( + from_sec_fragments(300), + NaiveTime::from_hms_opt(0, 0, 1).unwrap() + ); + } + + #[cfg(feature = "tds73")] + #[test] + fn from_mins_converts() { + // `from_mins` takes seconds-from-midnight; 3600 s == 01:00:00. + assert_eq!(from_mins(3600), NaiveTime::from_hms_opt(1, 0, 0).unwrap()); + } + + #[cfg(not(feature = "tds73"))] + #[test] + fn to_sec_fragments_converts() { + // One second == 300 sec-fragments (1/300 s units). + assert_eq!( + to_sec_fragments(NaiveTime::from_hms_opt(0, 0, 1).unwrap()), + 300 + ); + } + + #[cfg(feature = "tds73")] + #[test] + fn naive_date_round_trip() { + let date = NaiveDate::from_ymd_opt(2021, 6, 15).unwrap(); + let cd: ColumnData<'static> = date.into_sql(); + assert!(matches!(cd, ColumnData::Date(Some(_)))); + assert_eq!(NaiveDate::from_sql(&cd).unwrap(), Some(date)); + } + + #[cfg(feature = "tds73")] + #[test] + fn naive_time_round_trip() { + let time = NaiveTime::from_hms_opt(13, 37, 42).unwrap(); + let cd: ColumnData<'static> = time.into_sql(); + assert!(matches!(cd, ColumnData::Time(Some(_)))); + assert_eq!(NaiveTime::from_sql(&cd).unwrap(), Some(time)); + } + + #[cfg(feature = "tds73")] + #[test] + fn naive_datetime_round_trip() { + let dt = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2000, 12, 31).unwrap(), + NaiveTime::from_hms_opt(23, 59, 58).unwrap(), + ); + let cd: ColumnData<'static> = dt.into_sql(); + assert!(matches!(cd, ColumnData::DateTime2(Some(_)))); + assert_eq!(NaiveDateTime::from_sql(&cd).unwrap(), Some(dt)); + } + + #[cfg(feature = "tds73")] + #[test] + fn datetime_utc_round_trip() { + let naive = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2015, 3, 4).unwrap(), + NaiveTime::from_hms_opt(1, 2, 3).unwrap(), + ); + let dt = chrono::DateTime::::from_naive_utc_and_offset(naive, Utc); + let cd: ColumnData<'static> = dt.into_sql(); + assert!(matches!(cd, ColumnData::DateTime2(Some(_)))); + assert_eq!(chrono::DateTime::::from_sql(&cd).unwrap(), Some(dt)); + } + + #[cfg(feature = "tds73")] + #[test] + fn datetime_fixed_offset_round_trip() { + let offset = FixedOffset::east_opt(2 * 3600).unwrap(); + let naive = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2015, 3, 4).unwrap(), + NaiveTime::from_hms_opt(1, 2, 3).unwrap(), + ); + let dt = chrono::DateTime::from_naive_utc_and_offset(naive, offset); + let cd: ColumnData<'static> = dt.into_sql(); + assert!(matches!(cd, ColumnData::DateTimeOffset(Some(_)))); + assert_eq!( + chrono::DateTime::::from_sql(&cd).unwrap(), + Some(dt) + ); + } + + // The tds73 `from_sql` NaiveDateTime path has a dedicated arm for the legacy + // `ColumnData::DateTime` wire type; exercise it directly (round-trips produce + // `DateTime2`, never `DateTime`, so this arm is otherwise unreachable). + #[cfg(feature = "tds73")] + #[test] + fn naive_datetime_from_legacy_datetime_column() { + let cd = ColumnData::DateTime(Some(crate::tds::time::DateTime::new(0, 0))); + let dt = NaiveDateTime::from_sql(&cd).unwrap().unwrap(); + assert_eq!( + dt, + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1900, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + ) + ); + } + + // The `DateTimeOffset -> DateTime` conversion arm (distinct from the + // `DateTime` arm exercised by the round-trip test). + #[cfg(feature = "tds73")] + #[test] + fn datetime_offset_reads_as_utc() { + let offset = FixedOffset::east_opt(2 * 3600).unwrap(); + let naive = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2015, 3, 4).unwrap(), + NaiveTime::from_hms_opt(1, 2, 3).unwrap(), + ); + let dt: chrono::DateTime = + chrono::DateTime::from_naive_utc_and_offset(naive, offset); + let cd: ColumnData<'static> = dt.into_sql(); + assert!(matches!(cd, ColumnData::DateTimeOffset(Some(_)))); + + let utc = chrono::DateTime::::from_sql(&cd).unwrap(); + assert!(utc.is_some()); + } + + #[cfg(feature = "tds73")] + #[test] + fn null_maps_to_none() { + assert_eq!(NaiveDate::from_sql(&ColumnData::Date(None)).unwrap(), None); + assert_eq!(NaiveTime::from_sql(&ColumnData::Time(None)).unwrap(), None); + } + + #[cfg(not(feature = "tds73"))] + #[test] + fn naive_datetime_round_trip_legacy() { + let dt = NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + ); + let cd: ColumnData<'static> = dt.into_sql(); + assert!(matches!(cd, ColumnData::DateTime(Some(_)))); + assert_eq!(NaiveDateTime::from_sql(&cd).unwrap(), Some(dt)); + } +} diff --git a/src/tds/time/time.rs b/src/tds/time/time.rs index f036744bc..826d977ae 100644 --- a/src/tds/time/time.rs +++ b/src/tds/time/time.rs @@ -15,7 +15,25 @@ fn from_days(days: i64, start_year: i32) -> Date { // before `start_year`, e.g. `datetime` values prior to 1900) do not // overflow. Casting a negative day count into an unsigned type and // multiplying it out panics with "multiply with overflow". - Date::from_calendar_date(start_year, Month::January, 1).unwrap() + time::Duration::days(days) + // + // `days` ultimately comes from untrusted server bytes, so a malformed value + // can land outside the range `time::Date` can represent. Use `checked_add` + // and clamp to the type's bounds instead of panicking with "resulting value + // is out of range". + let base = Date::from_calendar_date(start_year, Month::January, 1).unwrap(); + base.checked_add(time::Duration::days(days)) + .unwrap_or(if days < 0 { Date::MIN } else { Date::MAX }) +} + +/// Convert a server-supplied fractional-seconds `increments` at the given +/// `scale` into nanoseconds without panicking. `scale` and `increments` are +/// untrusted; a `scale > 9` would otherwise underflow `9 - scale`, and a large +/// `increments` would overflow the multiply. +#[inline] +#[cfg(feature = "tds73")] +fn nanos_from_increments(increments: u64, scale: u8) -> u64 { + let pow = 9u32.saturating_sub(scale as u32); + increments.saturating_mul(10u64.saturating_pow(pow)) } #[inline] @@ -54,7 +72,7 @@ from_sql!( )), ColumnData::DateTime2(ref dt) => dt.map(|dt| PrimitiveDateTime::new( from_days(dt.date.days() as i64, 1), - Time::from_hms(0,0,0).unwrap() + Duration::from_nanos(dt.time.increments * 10u64.pow(9 - dt.time.scale as u32)) + Time::from_hms(0,0,0).unwrap() + Duration::from_nanos(nanos_from_increments(dt.time.increments, dt.time.scale)) )), ColumnData::DateTime(ref dt) => dt.map(|dt| PrimitiveDateTime::new( from_days(dt.days as i64, 1900), @@ -62,7 +80,7 @@ from_sql!( )); Time: ColumnData::Time(ref time) => time.map(|time| { - let ns = time.increments * 10u64.pow(9 - time.scale as u32); + let ns = nanos_from_increments(time.increments, time.scale); Time::from_hms(0,0,0).unwrap() + Duration::from_nanos(ns) }); Date: @@ -73,9 +91,12 @@ from_sql!( let dt = dto.datetime2; let time = Time::from_hms(0,0,0).unwrap() - + Duration::from_nanos(dt.time.increments * 10u64.pow(9 - dt.time.scale as u32)); + + Duration::from_nanos(nanos_from_increments(dt.time.increments, dt.time.scale)); - let offset = UtcOffset::from_whole_seconds(dto.offset as i32 * 60).unwrap(); + // A malformed server offset outside ±14h is not representable by + // `UtcOffset`; fall back to UTC rather than panicking. + let offset = UtcOffset::from_whole_seconds(dto.offset as i32 * 60) + .unwrap_or(UtcOffset::UTC); date.with_time(time).assume_utc().to_offset(offset) }) @@ -178,4 +199,191 @@ mod tests { assert_eq!(decoded.date(), expected_date); assert_eq!(decoded.time(), Time::from_hms(0, 0, 0).unwrap()); } + + #[test] + fn from_days_clamps_on_overflow() { + // A day offset far outside the representable `time::Date` range forces + // the `checked_add` fallback. Positive overflow clamps to MAX, negative + // to MIN. Pins the `days < 0` sign test (vs `==` / `>`). + assert_eq!(from_days(10_000_000, 1), Date::MAX); + assert_eq!(from_days(-10_000_000, 1), Date::MIN); + } + + #[cfg(feature = "tds73")] + #[test] + fn from_secs_converts() { + // 3600 s past midnight == 01:00:00 (pins the `+` in `from_secs`). + assert_eq!(from_secs(3600), Time::from_hms(1, 0, 0).unwrap()); + } + + #[test] + fn from_sec_fragments_converts() { + // 300 sec-fragments (1/300 s units) == exactly one second. + assert_eq!(from_sec_fragments(300), Time::from_hms(0, 0, 1).unwrap()); + } + + #[cfg(not(feature = "tds73"))] + #[test] + fn to_sec_fragments_converts() { + // One second == 300 sec-fragments (1/300 s units). + assert_eq!(to_sec_fragments(Time::from_hms(0, 0, 1).unwrap()), 300); + } + + #[cfg(feature = "tds73")] + #[test] + fn time_from_sql_and_back() { + use crate::FromSql; + + // 12:34:56 as 100ns increments since midnight, scale 7. + let expected = Time::from_hms(12, 34, 56).unwrap(); + let nanos: u64 = (expected - Time::from_hms(0, 0, 0).unwrap()) + .whole_nanoseconds() + .try_into() + .unwrap(); + let increments: u64 = nanos / 100; + + let tds_time = super::super::Time::new(increments, 7); + let data = ColumnData::Time(Some(tds_time)); + + let decoded = Time::from_sql(&data).unwrap().unwrap(); + assert_eq!(decoded, expected); + + // Round trip back through ToSql. + use crate::ToSql; + let round_tripped = decoded.to_sql(); + match round_tripped { + ColumnData::Time(Some(t)) => assert_eq!(t.increments, increments), + other => panic!("unexpected: {:?}", other), + } + } + + #[cfg(feature = "tds73")] + #[test] + fn date_from_sql_and_back() { + use crate::{FromSql, ToSql}; + + let expected = Date::from_calendar_date(2020, Month::June, 15).unwrap(); + let days = to_days(expected, 1) as u32; + + let data = ColumnData::Date(Some(super::super::Date::new(days))); + let decoded = Date::from_sql(&data).unwrap().unwrap(); + assert_eq!(decoded, expected); + + match decoded.to_sql() { + ColumnData::Date(Some(d)) => assert_eq!(d.days(), days), + other => panic!("unexpected: {:?}", other), + } + } + + #[cfg(feature = "tds73")] + #[test] + fn primitive_datetime_from_datetime2_and_back() { + use crate::{FromSql, ToSql}; + + let date = Date::from_calendar_date(2020, Month::June, 15).unwrap(); + let time = Time::from_hms(1, 2, 3).unwrap(); + let expected = PrimitiveDateTime::new(date, time); + + let days = to_days(date, 1) as u32; + let nanos: u64 = (time - Time::from_hms(0, 0, 0).unwrap()) + .whole_nanoseconds() + .try_into() + .unwrap(); + let increments = nanos / 100; + + let dt2 = super::super::DateTime2::new( + super::super::Date::new(days), + super::super::Time::new(increments, 7), + ); + let data = ColumnData::DateTime2(Some(dt2)); + + let decoded = PrimitiveDateTime::from_sql(&data).unwrap().unwrap(); + assert_eq!(decoded, expected); + + match decoded.to_sql() { + ColumnData::DateTime2(Some(dt)) => { + assert_eq!(dt.date.days(), days); + } + other => panic!("unexpected: {:?}", other), + } + } + + #[cfg(feature = "tds73")] + #[test] + fn primitive_datetime_from_smalldatetime_and_datetime() { + use crate::FromSql; + + // SmallDateTime path. + let sdt = crate::tds::time::SmallDateTime::new(1, 30); // 30 minutes past midnight on day 1 (1900-01-02) + let data = ColumnData::SmallDateTime(Some(sdt)); + let decoded = PrimitiveDateTime::from_sql(&data).unwrap().unwrap(); + assert_eq!(decoded.date(), from_days(1, 1900)); + + // DateTime path. + let dt = crate::tds::time::DateTime::new(1, 0); + let data = ColumnData::DateTime(Some(dt)); + let decoded = PrimitiveDateTime::from_sql(&data).unwrap().unwrap(); + assert_eq!(decoded.date(), from_days(1, 1900)); + } + + #[cfg(feature = "tds73")] + #[test] + fn offset_date_time_from_sql_and_back() { + use crate::{FromSql, ToSql}; + + let date = Date::from_calendar_date(2020, Month::June, 15).unwrap(); + let time = Time::from_hms(1, 2, 3).unwrap(); + let days = to_days(date, 1) as u32; + let nanos: u64 = (time - Time::from_hms(0, 0, 0).unwrap()) + .whole_nanoseconds() + .try_into() + .unwrap(); + let increments = nanos / 100; + + let dt2 = super::super::DateTime2::new( + super::super::Date::new(days), + super::super::Time::new(increments, 7), + ); + let dto = super::super::DateTimeOffset::new(dt2, 60); // +1h offset + + let data = ColumnData::DateTimeOffset(Some(dto)); + let decoded = OffsetDateTime::from_sql(&data).unwrap().unwrap(); + + assert_eq!( + decoded.offset(), + UtcOffset::from_whole_seconds(3600).unwrap() + ); + + match decoded.to_sql() { + ColumnData::DateTimeOffset(Some(round_tripped)) => { + assert_eq!(round_tripped.offset, 60); + } + other => panic!("unexpected: {:?}", other), + } + } + + // `ColumnData::Time`/`Date`/`DateTimeOffset` and their `time`-crate + // `FromSql` impls only exist with the `tds73` feature. + #[cfg(feature = "tds73")] + #[test] + fn from_sql_null_variants_return_none_tds73() { + use crate::FromSql; + + assert_eq!(Time::from_sql(&ColumnData::Time(None)).unwrap(), None); + assert_eq!(Date::from_sql(&ColumnData::Date(None)).unwrap(), None); + assert_eq!( + OffsetDateTime::from_sql(&ColumnData::DateTimeOffset(None)).unwrap(), + None + ); + } + + #[test] + fn primitive_datetime_from_sql_null_returns_none() { + use crate::FromSql; + + assert_eq!( + PrimitiveDateTime::from_sql(&ColumnData::DateTime(None)).unwrap(), + None + ); + } } diff --git a/src/tds/xml.rs b/src/tds/xml.rs index 8e0dc9f1e..008d0e6a8 100644 --- a/src/tds/xml.rs +++ b/src/tds/xml.rs @@ -117,3 +117,64 @@ impl Encode for XmlData { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xml_schema_accessors() { + let schema = XmlSchema::new("db", "owner", "collection"); + assert_eq!(schema.db_name(), "db"); + assert_eq!(schema.owner(), "owner"); + assert_eq!(schema.collection(), "collection"); + } + + #[test] + fn xml_schema_eq_and_clone() { + let a = XmlSchema::new("db", "owner", "collection"); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn xml_data_without_schema() { + let data = XmlData::new(""); + assert!(data.schema().is_none()); + assert_eq!(data.as_ref(), ""); + assert_eq!(format!("{}", data), ""); + assert_eq!(data.into_string(), ""); + } + + #[test] + fn xml_data_with_schema() { + let schema = Arc::new(XmlSchema::new("db", "owner", "collection")); + let mut data = XmlData::new("1"); + data.set_schema(schema.clone()); + + let stored = data.schema().expect("schema present"); + assert_eq!(stored.db_name(), "db"); + assert_eq!(stored.owner(), "owner"); + assert_eq!(stored.collection(), "collection"); + } + + #[test] + fn encode_writes_plp_header_and_backpatches_length() { + let mut buf = BytesMut::new(); + XmlData::new("ab") + .encode(&mut buf) + .expect("encode succeeds"); + + // 8 (unknown-size marker) + 4 (length) + 2*2 (utf16 chars) + 4 (terminator) + assert_eq!(buf.len(), 8 + 4 + 4 + 4); + + // unknown size marker + assert_eq!(&buf[0..8], &0xfffffffffffffffe_u64.to_le_bytes()); + // backpatched length is number of chars * 2 bytes + assert_eq!(&buf[8..12], &(4u32).to_le_bytes()); + // 'a' then 'b' as UTF-16LE + assert_eq!(&buf[12..16], &[b'a', 0, b'b', 0]); + // PLP terminator + assert_eq!(&buf[16..20], &(0u32).to_le_bytes()); + } +} diff --git a/src/to_sql.rs b/src/to_sql.rs index cde353cd1..6e8b8ddf8 100644 --- a/src/to_sql.rs +++ b/src/to_sql.rs @@ -199,3 +199,208 @@ to_sql!(self_, XmlData: (ColumnData::Xml, Cow::Borrowed(self_)); Uuid: (ColumnData::Guid, *self_); ); + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::Numeric; + use crate::{IntoSql, ToSql}; + + #[test] + fn to_sql_scalars() { + assert_eq!(true.to_sql(), ColumnData::Bit(Some(true))); + assert_eq!(8u8.to_sql(), ColumnData::U8(Some(8))); + assert_eq!(16i16.to_sql(), ColumnData::I16(Some(16))); + assert_eq!(32i32.to_sql(), ColumnData::I32(Some(32))); + assert_eq!(64i64.to_sql(), ColumnData::I64(Some(64))); + assert_eq!(1.5f32.to_sql(), ColumnData::F32(Some(1.5))); + assert_eq!(2.5f64.to_sql(), ColumnData::F64(Some(2.5))); + } + + #[test] + // The `&Some(..)`/`&None` borrows are intentional: they exercise the + // `ToSql for &T` impls, not the by-value ones, so the borrow is not needless. + #[allow(clippy::needless_borrow)] + fn to_sql_option_some_and_none() { + assert_eq!(Some(1i32).to_sql(), ColumnData::I32(Some(1))); + assert_eq!(None::.to_sql(), ColumnData::I32(None)); + assert_eq!((&Some(1i32)).to_sql(), ColumnData::I32(Some(1))); + assert_eq!((&None::).to_sql(), ColumnData::I32(None)); + } + + #[test] + fn to_sql_strings_and_binary() { + assert_eq!("abc".to_sql(), ColumnData::String(Some(Cow::from("abc")))); + assert_eq!( + String::from("abc").to_sql(), + ColumnData::String(Some(Cow::from("abc"))) + ); + let v = vec![1u8, 2, 3]; + assert_eq!( + v.to_sql(), + ColumnData::Binary(Some(Cow::from(vec![1, 2, 3]))) + ); + assert_eq!( + [1u8, 2, 3].as_slice().to_sql(), + ColumnData::Binary(Some(Cow::from(vec![1, 2, 3]))) + ); + } + + #[test] + fn to_sql_numeric_and_uuid() { + let n = Numeric::new_with_scale(5, 1); + assert_eq!(n.to_sql(), ColumnData::Numeric(Some(n))); + + let uuid = Uuid::nil(); + assert_eq!(uuid.to_sql(), ColumnData::Guid(Some(uuid))); + } + + #[test] + fn into_sql_borrowed_and_owned() { + assert_eq!( + "abc".into_sql(), + ColumnData::String(Some(Cow::Borrowed("abc"))) + ); + assert_eq!( + Some("abc").into_sql(), + ColumnData::String(Some(Cow::Borrowed("abc"))) + ); + assert_eq!(None::<&str>.into_sql(), ColumnData::String(None)); + + let bytes = vec![9u8, 8, 7]; + assert_eq!( + bytes.as_slice().into_sql(), + ColumnData::Binary(Some(Cow::Borrowed(bytes.as_slice()))) + ); + assert_eq!( + (&bytes).into_sql(), + ColumnData::Binary(Some(Cow::from(&bytes))) + ); + + let uuid = Uuid::nil(); + assert_eq!((&uuid).into_sql(), ColumnData::Guid(Some(uuid))); + assert_eq!(Some(&uuid).into_sql(), ColumnData::Guid(Some(uuid))); + assert_eq!(None::<&Uuid>.into_sql(), ColumnData::Guid(None)); + } + + #[test] + fn into_sql_scalars() { + assert_eq!(true.into_sql(), ColumnData::Bit(Some(true))); + assert_eq!(5i32.into_sql(), ColumnData::I32(Some(5))); + assert_eq!(None::.into_sql(), ColumnData::I32(None)); + assert_eq!( + String::from("x").into_sql(), + ColumnData::String(Some(Cow::from("x"))) + ); + } + + #[test] + fn into_sql_owned_string_and_ref() { + let owned = String::from("abc"); + assert_eq!( + (&owned).into_sql(), + ColumnData::String(Some(Cow::from("abc"))) + ); + assert_eq!( + Some(&owned).into_sql(), + ColumnData::String(Some(Cow::from("abc"))) + ); + assert_eq!(None::<&String>.into_sql(), ColumnData::String(None)); + } + + #[test] + fn into_sql_binary_option_variants() { + assert_eq!(None::<&[u8]>.into_sql(), ColumnData::Binary(None)); + + let bytes = vec![1u8, 2, 3]; + assert_eq!( + Some(bytes.as_slice()).into_sql(), + ColumnData::Binary(Some(Cow::from(bytes.as_slice()))) + ); + assert_eq!(None::<&Vec>.into_sql(), ColumnData::Binary(None)); + assert_eq!( + bytes.into_sql(), + ColumnData::Binary(Some(Cow::from(vec![1, 2, 3]))) + ); + } + + #[test] + fn into_sql_cow_variants() { + let cow_str: Cow<'_, str> = Cow::Borrowed("hi"); + assert_eq!( + cow_str.into_sql(), + ColumnData::String(Some(Cow::from("hi"))) + ); + assert_eq!( + Some(Cow::Borrowed("hi")).into_sql(), + ColumnData::String(Some(Cow::from("hi"))) + ); + assert_eq!(None::>.into_sql(), ColumnData::String(None)); + + let cow_bin: Cow<'_, [u8]> = Cow::Borrowed(&[1u8, 2][..]); + assert_eq!( + cow_bin.into_sql(), + ColumnData::Binary(Some(Cow::from(vec![1u8, 2]))) + ); + assert_eq!( + Some(Cow::<[u8]>::Borrowed(&[1u8, 2][..])).into_sql(), + ColumnData::Binary(Some(Cow::from(vec![1u8, 2]))) + ); + assert_eq!(None::>.into_sql(), ColumnData::Binary(None)); + } + + #[test] + fn into_sql_xml_and_numeric() { + let xml = XmlData::new("".to_string()); + assert_eq!( + (&xml).into_sql(), + ColumnData::Xml(Some(Cow::Borrowed(&xml))) + ); + assert_eq!( + Some(&xml).into_sql(), + ColumnData::Xml(Some(Cow::Borrowed(&xml))) + ); + assert_eq!(None::<&XmlData>.into_sql(), ColumnData::Xml(None)); + + let xml_owned = XmlData::new("".to_string()); + assert_eq!( + xml_owned.clone().into_sql(), + ColumnData::Xml(Some(Cow::Owned(xml_owned))) + ); + + let n = Numeric::new_with_scale(42, 0); + assert_eq!(n.into_sql(), ColumnData::Numeric(Some(n))); + } + + #[test] + // The `&value` borrows are intentional: they exercise the `ToSql for &T` + // impls for the base scalar types, so the borrow is not needless. + #[allow(clippy::needless_borrow)] + fn to_sql_by_reference_scalars() { + // The macro-generated impls also cover `&T` for the base scalar types. + assert_eq!((&true).to_sql(), ColumnData::Bit(Some(true))); + assert_eq!((&8u8).to_sql(), ColumnData::U8(Some(8))); + assert_eq!((&16i16).to_sql(), ColumnData::I16(Some(16))); + assert_eq!((&64i64).to_sql(), ColumnData::I64(Some(64))); + assert_eq!((&1.5f32).to_sql(), ColumnData::F32(Some(1.5))); + assert_eq!((&2.5f64).to_sql(), ColumnData::F64(Some(2.5))); + } + + #[test] + fn to_sql_cow_variants() { + let cow_str: Cow<'_, str> = Cow::Borrowed("hi"); + assert_eq!(cow_str.to_sql(), ColumnData::String(Some(Cow::from("hi")))); + + let cow_bin: Cow<'_, [u8]> = Cow::Borrowed(&[1u8, 2][..]); + assert_eq!( + cow_bin.to_sql(), + ColumnData::Binary(Some(Cow::from(vec![1u8, 2]))) + ); + } + + #[test] + fn to_sql_xml() { + let xml = XmlData::new("".to_string()); + assert_eq!(xml.to_sql(), ColumnData::Xml(Some(Cow::Borrowed(&xml)))); + } +} From 6065d9d09f40f3b457e181994727f94916284deb Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:51:54 -0700 Subject: [PATCH 2/2] fix: correct false prelogin comment; drop mutation-test noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pre_login: comment claimed unknown tokens are skipped, but they are rejected as a protocol error (matching the existing test) — fix the comment - revert the 1<<0 -> 1 churn and drop the "shift-invariant" comments - trim mutation-testing narration comments across codec/token modules - remove "previously…" history comments (fixed_len, token_col_metadata, numeric) - sql_read_bytes: use std::pin::pin! instead of unsafe Pin::new_unchecked in a test --- src/sql_read_bytes.rs | 7 ++----- src/tds/codec/column_data.rs | 5 ----- src/tds/codec/column_data/fixed_len.rs | 4 +--- src/tds/codec/column_data/money.rs | 8 +------- src/tds/codec/column_data/plp.rs | 16 ++++------------ src/tds/codec/login.rs | 8 ++++---- src/tds/codec/pre_login.rs | 3 +-- src/tds/codec/token/token_col_info.rs | 8 ++------ src/tds/codec/token/token_col_metadata.rs | 8 +++----- src/tds/codec/token/token_done.rs | 4 ++-- src/tds/codec/token/token_error.rs | 7 +++---- src/tds/codec/token/token_fed_auth_info.rs | 11 +++-------- src/tds/codec/token/token_info.rs | 3 +-- src/tds/codec/token/token_row.rs | 5 ++--- src/tds/codec/token/token_session_state.rs | 17 +++++------------ src/tds/numeric.rs | 3 +-- 16 files changed, 35 insertions(+), 82 deletions(-) diff --git a/src/sql_read_bytes.rs b/src/sql_read_bytes.rs index 75fb20acd..d295f02d3 100644 --- a/src/sql_read_bytes.rs +++ b/src/sql_read_bytes.rs @@ -532,11 +532,8 @@ mod poll_branch_tests { fn poll_once(fut: F) -> Poll { let waker = std::task::Waker::noop(); let mut cx = TaskContext::from_waker(waker); - let mut fut = fut; - // Safety: `fut` lives on the stack for the duration of this call and is - // never moved after being pinned. - let fut = unsafe { Pin::new_unchecked(&mut fut) }; - fut.poll(&mut cx) + let mut fut = std::pin::pin!(fut); + fut.as_mut().poll(&mut cx) } // The varchar length read yields `Pending` (no bytes available yet). diff --git a/src/tds/codec/column_data.rs b/src/tds/codec/column_data.rs index 259e9c586..1aab49f01 100644 --- a/src/tds/codec/column_data.rs +++ b/src/tds/codec/column_data.rs @@ -1787,11 +1787,6 @@ mod tests { assert!(matches!(err, Error::BulkInput(_)), "got {:?}", err); } - // NOTE: the `ntext(max)` catch-all in type_name is only reached by a string - // longer than MAX_NVARCHAR_SIZE (>1 GiB); allocating that in a unit test is - // impractical (slow / OOM-prone in CI), so that single line is intentionally - // left uncovered. - // ----- decode: line 199 (VarLenSizedPrecision non-numeric -> todo!()) ----- #[tokio::test] diff --git a/src/tds/codec/column_data/fixed_len.rs b/src/tds/codec/column_data/fixed_len.rs index 691c36998..b29b26836 100644 --- a/src/tds/codec/column_data/fixed_len.rs +++ b/src/tds/codec/column_data/fixed_len.rs @@ -11,9 +11,7 @@ where // Wire type 0x1F (MS-TDS 2.2.5.4.1) carries no data and represents a // typeless NULL. Surface it as `I32(None)` to match both the NBCROW // packed-null path (`BaseMetaDataColumn::null_value`) and the column's - // own `Display` ("int"); previously this ROW path returned `Bit(None)`, - // so the same `SELECT NULL` column decoded to a different variant - // depending on whether the server packed the row. + // own `Display` ("int"). FixedLenType::Null => ColumnData::I32(None), FixedLenType::Bit => ColumnData::Bit(Some(src.read_u8().await? != 0)), FixedLenType::Int1 => ColumnData::U8(Some(src.read_u8().await?)), diff --git a/src/tds/codec/column_data/money.rs b/src/tds/codec/column_data/money.rs index 1cf754c1a..1d5f705c9 100644 --- a/src/tds/codec/column_data/money.rs +++ b/src/tds/codec/column_data/money.rs @@ -51,10 +51,7 @@ mod tests { use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; - // `smallmoney` (len 4) is a single scaled `i32` divided by 1e4. Uses a value - // that is not a boundary: kills "delete match arm 4" (would fall through to - // the error arm) and "replace `/` with `%`/`*`" (12345/1e4 = 1.2345, whereas - // 12345 % 1e4 = 2345.0 and 12345 * 1e4 = 1.2345e8). + // `smallmoney` (len 4) is a single scaled `i32` divided by 1e4: 12345 -> 1.2345. #[tokio::test] async fn decode_smallmoney_arm() { let mut buf = BytesMut::new(); @@ -69,9 +66,6 @@ mod tests { // `money` (len 8) is two 32-bit words: `((high << 32) + low) / 1e4`. // high = 1, low = 30000 gives ((1 << 32) + 30000) / 1e4 = 429499.7296. - // Kills: "delete match arm 8" (error fallthrough); "replace `<<` with `>>`" - // (1 >> 32 = 0 => 3.0); "replace `+` with `-`/`*`" (subtraction/mult differ); - // and "replace outer `/` with `%`/`*`" (4294997296 % 1e4 = 7296.0). #[tokio::test] async fn decode_money_arm() { let mut buf = BytesMut::new(); diff --git a/src/tds/codec/column_data/plp.rs b/src/tds/codec/column_data/plp.rs index e2d133801..addac62ab 100644 --- a/src/tds/codec/column_data/plp.rs +++ b/src/tds/codec/column_data/plp.rs @@ -90,13 +90,8 @@ mod tests { use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; - // The `len` argument selects the wire layout: `len < 0xffff` means a plain - // `u16`-prefixed value, otherwise a `u64`-prefixed chunked (PLP) value. At - // the boundary `len == 0xffff` the real code takes the chunked branch - // (reads a `u64` length). Mutating `<` to `<=` would take the fixed branch - // (reads a `u16` length) and produce a different value. We feed a chunked - // stream that decodes to [0xAA, 0xBB, 0xCC]; under the `<=` mutant the same - // bytes are misread as a `u16` length of 5 followed by five zero bytes. + // At the boundary `len == 0xffff` the decoder takes the chunked (PLP) branch, + // reading a `u64` length; the chunked stream decodes to [0xAA, 0xBB, 0xCC]. #[tokio::test] async fn decode_boundary_len_uses_chunked_branch() { let mut buf = BytesMut::new(); @@ -112,11 +107,8 @@ mod tests { } // A chunk whose running total strictly exceeds `MAX_PLP_SIZE` must be - // rejected with the "exceeds the maximum" protocol error *before* any chunk - // data is read. Mutating `>` to `==` would let a strictly-greater total slip - // past the guard (the `==` only fires at the exact boundary), so instead of - // the protocol error the decoder would try to read a ~4 GiB chunk and fail - // with an unrelated read error. + // rejected with the "exceeds the maximum" protocol error before any chunk + // data is read. #[tokio::test] async fn decode_oversized_chunk_is_rejected() { let mut buf = BytesMut::new(); diff --git a/src/tds/codec/login.rs b/src/tds/codec/login.rs index 96918c6de..daeb98a1d 100644 --- a/src/tds/codec/login.rs +++ b/src/tds/codec/login.rs @@ -39,7 +39,7 @@ impl FeatureLevel { pub enum OptionFlag1 { /// The byte order used by client for numeric and datetime data types. /// (default: little-endian) - BigEndian = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) + BigEndian = 1 << 0, /// The character set used on the client. (default: ASCII) CharsetEBDDIC = 1 << 1, /// Use VAX floating point representation. (default: IEEE 754) @@ -68,7 +68,7 @@ pub enum OptionFlag1 { pub enum OptionFlag2 { /// Set if the change to initial language needs to succeed if the connect is /// to succeed. - InitLangFatal = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) + InitLangFatal = 1 << 0, /// Set if the client is the ODBC driver. This causes the server to set /// `ANSI_DEFAULTS=ON`, `CURSOR_CLOSE_ON_COMMIT`, `IMPLICIT_TRANSACTIONS=OFF`, /// `TEXTSIZE=0x7FFFFFFF` (2GB) (TDS 7.2 and earlier) `TEXTSIZE` to infinite @@ -93,7 +93,7 @@ pub enum OptionFlag2 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OptionFlag3 { /// Request to change login's password. - RequestChangePassword = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) + RequestChangePassword = 1 << 0, /// XML data type instances are returned as binary XML. BinaryXML = 1 << 1, /// Client is requesting separate process to be spawned as user instance. @@ -112,7 +112,7 @@ pub enum OptionFlag3 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginTypeFlag { /// Use T-SQL syntax. - UseTSQL = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) + UseTSQL = 1 << 0, /// Set if the client is the OLEDB driver. This causes the server to set /// ANSI_DEFAULTS to ON, CURSOR_CLOSE_ON_COMMIT and IMPLICIT_TRANSACTIONS to /// OFF, TEXTSIZE to 0x7FFFFFFF (2GB) (TDS 7.2 and earlier), TEXTSIZE to diff --git a/src/tds/codec/pre_login.rs b/src/tds/codec/pre_login.rs index d9016051c..9b877b23d 100644 --- a/src/tds/codec/pre_login.rs +++ b/src/tds/codec/pre_login.rs @@ -236,8 +236,7 @@ impl Decode for PreloginMessage { // verify whether the server acts in accordance to what we requested // and if we can handle on what we seemingly agreed to - // Unrecognized (e.g. newer) pre-login option tokens are skipped; - // this is intentional forward-compatibility, not a bug. + // Unrecognized pre-login option tokens are rejected as a protocol error. match token { // version PRELOGIN_VERSION => { diff --git a/src/tds/codec/token/token_col_info.rs b/src/tds/codec/token/token_col_info.rs index 07b3b7432..92e92948f 100644 --- a/src/tds/codec/token/token_col_info.rs +++ b/src/tds/codec/token/token_col_info.rs @@ -174,9 +174,7 @@ mod tests { assert!(!expr.is_key()); assert!(!expr.is_hidden()); - // Key-only: is_key true, the others false. (Status 0x08 shares no bits - // with STATUS_EXPRESSION 0x04, so `&`->`|`/`^` would wrongly report an - // expression here.) + // Key-only: is_key true, the others false. let key = ColInfo { col_num: 1, table_num: 0, @@ -203,9 +201,7 @@ mod tests { async fn decode_col_info_advances_consumed_by_name_bytes() { // A different-name column (with a multi-character name) followed by a // plain column. The `consumed += char_len * 2` update must be exact for - // the loop to read *both* columns: `+=`->`*=` would overshoot and stop - // after the first column, `*`->`+` would undershoot and run off the end - // of the buffer. + // the loop to read both columns. let mut body = BytesMut::new(); // Column 1: expression + different name "abc" (3 chars => 6 bytes). diff --git a/src/tds/codec/token/token_col_metadata.rs b/src/tds/codec/token/token_col_metadata.rs index f062fcec5..b2349cc5c 100644 --- a/src/tds/codec/token/token_col_metadata.rs +++ b/src/tds/codec/token/token_col_metadata.rs @@ -227,8 +227,7 @@ impl BaseMetaDataColumn { VarLenType::Xml => ColumnData::Xml(None), // A null CLR UDT carries no payload; surface it as a null // binary, matching `udt::decode` (which yields - // `ColumnData::Binary`). Previously this panicked via `todo!()`, - // which a bulk insert of a NULL UDT column could reach. + // `ColumnData::Binary`). VarLenType::Udt => ColumnData::Binary(None), VarLenType::Text => ColumnData::String(None), VarLenType::Image => ColumnData::Binary(None), @@ -263,8 +262,7 @@ impl BaseMetaDataColumn { VarLenType::Xml => ColumnData::Xml(None), // A null CLR UDT carries no payload; surface it as a null // binary, matching `udt::decode` (which yields - // `ColumnData::Binary`). Previously this panicked via `todo!()`, - // which a bulk insert of a NULL UDT column could reach. + // `ColumnData::Binary`). VarLenType::Udt => ColumnData::Binary(None), VarLenType::Text => ColumnData::String(None), VarLenType::Image => ColumnData::Binary(None), @@ -329,7 +327,7 @@ impl Encode for BaseMetaDataColumn { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColumnFlag { /// The column can be null. - Nullable = 1, + Nullable = 1 << 0, /// Set for string columns with binary collation and always for the XML data /// type. CaseSensitive = 1 << 1, diff --git a/src/tds/codec/token/token_done.rs b/src/tds/codec/token/token_done.rs index d655210c3..d6a4fd666 100644 --- a/src/tds/codec/token/token_done.rs +++ b/src/tds/codec/token/token_done.rs @@ -15,7 +15,7 @@ pub struct TokenDone { #[repr(u16)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DoneStatus { - More = 1, // bit 0 (literal 1: `1 << 0` is shift-invariant) + More = 1 << 0, Error = 1 << 1, Inexact = 1 << 2, // reserved @@ -144,7 +144,7 @@ mod tests { #[tokio::test] async fn decode_reads_four_byte_rowcount_on_pre_2005_versions() { // Pre-2005 servers encode the DONE rowcount in 4 bytes; the decoder must - // pick the 4-byte arm from the negotiated version (kills "delete arm 4"). + // pick the 4-byte arm from the negotiated version. use crate::sql_read_bytes::SqlReadBytes; use crate::tds::codec::login::FeatureLevel; diff --git a/src/tds/codec/token/token_error.rs b/src/tds/codec/token/token_error.rs index 3727eb4f5..4410f0a60 100644 --- a/src/tds/codec/token/token_error.rs +++ b/src/tds/codec/token/token_error.rs @@ -187,10 +187,9 @@ mod tests { #[tokio::test] async fn decode_reads_full_four_byte_line_number_on_tds72_plus() { // The default test context reports SqlServerN (>= TDS 7.2), so the - // LineNumber must be read as a 4-byte LONG. A `>` mutation of the - // `>=` boundary check would read only 2 bytes and mis-decode the value. - // 0x0001_0001 (65537) has distinct low-16-bit and full-32-bit values, so - // a 2-byte read yields 1 while the correct 4-byte read yields 65537. + // LineNumber must be read as a 4-byte LONG. 0x0001_0001 (65537) has + // distinct low-16-bit and full-32-bit values, so a 2-byte read yields 1 + // while the correct 4-byte read yields 65537. use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; diff --git a/src/tds/codec/token/token_fed_auth_info.rs b/src/tds/codec/token/token_fed_auth_info.rs index b8f5441e3..6bda9baf6 100644 --- a/src/tds/codec/token/token_fed_auth_info.rs +++ b/src/tds/codec/token/token_fed_auth_info.rs @@ -194,10 +194,7 @@ mod tests { #[tokio::test] async fn decode_reads_length_prefix_and_parses_body() { // Exercises the full `decode` path: reading the 4-byte TokenLength, the - // length bound check, reading the body, and parsing it. A mutation that - // short-circuits `decode` to `Ok(Default::default())` would drop the - // parsed STSURL, and a `<` mutation of the length bound check would - // reject this (well-under-maximum) token outright. + // length bound check, reading the body, and parsing it into the STSURL. use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; @@ -295,10 +292,8 @@ mod tests { #[tokio::test] async fn decode_accepts_token_length_at_maximum() { - // The length bound check is `token_length > MAX_TOKEN_BODY`, so a token - // whose length is exactly MAX_TOKEN_BODY must be accepted. `>=` or `==` - // mutations of the `>` would reject it. The body is a valid, empty - // (CountOfInfoIDs == 0) token padded out to the maximum length. + // A token whose length is exactly MAX_TOKEN_BODY must be accepted. The + // body is a valid, empty (CountOfInfoIDs == 0) token padded to the maximum. use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; diff --git a/src/tds/codec/token/token_info.rs b/src/tds/codec/token/token_info.rs index 9557a2ba4..a0edc281b 100644 --- a/src/tds/codec/token/token_info.rs +++ b/src/tds/codec/token/token_info.rs @@ -100,8 +100,7 @@ mod tests { #[tokio::test] async fn decode_reads_full_four_byte_line_number_on_tds72_plus() { // The default test context reports SqlServerN (>= TDS 7.2), so the - // LineNumber must be read as a 4-byte LONG. A `>` mutation of the `>=` - // boundary check would read only 2 bytes. 0x0001_0001 (65537) reads as 1 + // LineNumber must be read as a 4-byte LONG. 0x0001_0001 (65537) reads as 1 // when truncated to 2 bytes but as 65537 when read correctly as 4 bytes. let mut buf = BytesMut::new(); buf.put_u16_le(0); // length, ignored diff --git a/src/tds/codec/token/token_row.rs b/src/tds/codec/token/token_row.rs index 7117c27a9..f7265f1ff 100644 --- a/src/tds/codec/token/token_row.rs +++ b/src/tds/codec/token/token_row.rs @@ -260,9 +260,8 @@ mod tests { #[test] fn row_bitmap_is_null_checks_correct_bit() { - // Only bit 3 is set in the single bitmap byte. is_null must consult that - // exact bit; a `<<`->`>>` mutation would look at bit -3 (i.e. 0) and - // report the wrong columns. + // Only bit 3 is set in the single bitmap byte; is_null must consult that + // exact bit. let bitmap = RowBitmap { data: vec![0b0000_1000], }; diff --git a/src/tds/codec/token/token_session_state.rs b/src/tds/codec/token/token_session_state.rs index d974d99fc..a34a3fd7c 100644 --- a/src/tds/codec/token/token_session_state.rs +++ b/src/tds/codec/token/token_session_state.rs @@ -207,10 +207,7 @@ mod tests { #[test] fn parse_rejects_state_len_exceeding_remaining() { // StateLen (5) is larger than the bytes actually remaining after the - // header (3), so it must be rejected as a protocol error. This exercises - // `remaining = total - position`: a `-`->`+` mutation would compute a - // much larger "remaining" and wrongly accept the length (then fail later - // with an I/O error instead). + // header (3), so it must be rejected as a protocol error. let mut body = Vec::new(); body.extend_from_slice(&1u32.to_le_bytes()); // SeqNo body.push(0x00); // Status @@ -228,8 +225,7 @@ mod tests { use crate::sql_read_bytes::test_utils::IntoSqlReadBytes; use bytes::{BufMut, BytesMut}; - // len == 5: exactly SeqNo + Status, no states. The `bytes.len() < 5` - // check must NOT reject this boundary (kills `<`->`<=`/`==`). + // len == 5: exactly SeqNo + Status, no states. This boundary must decode. let mut buf = BytesMut::new(); buf.put_u32_le(5); buf.put_u32_le(1); // SeqNo @@ -242,8 +238,7 @@ mod tests { assert_eq!(token.status, 0x01); assert!(token.states.is_empty()); - // len == 8: a full token with one state value. Must decode fine (kills - // `<`->`>`, which would reject lengths above 5). + // len == 8: a full token with one state value. Must decode fine. let mut buf = BytesMut::new(); buf.put_u32_le(8); buf.put_u32_le(2); // SeqNo @@ -267,8 +262,7 @@ mod tests { use bytes::{BufMut, BytesMut}; // len == MAX_TOKEN_BODY + 1: over the cap, so a protocol error is - // returned immediately (kills `>`->`<`/`==`, which would not trip here - // and would instead fail later with an I/O error). + // returned immediately. let mut buf = BytesMut::new(); buf.put_u32_le((super::super::MAX_TOKEN_BODY + 1) as u32); buf.put_u32_le(0); // a few bytes so the read gets that far @@ -279,8 +273,7 @@ mod tests { assert!(matches!(err, Error::Protocol(_))); // len == MAX_TOKEN_BODY exactly: at the boundary the length check must - // NOT fire (kills `>`->`>=`). The buffer is truncated, so the real code - // proceeds past the check and fails with an I/O error instead. + // NOT fire. The buffer is truncated, so decoding fails with an I/O error. let mut buf = BytesMut::new(); buf.put_u32_le(super::super::MAX_TOKEN_BODY as u32); buf.put_u32_le(0); // far fewer than MAX bytes follow diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index ba38d3656..5403bbc13 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -112,8 +112,7 @@ impl Numeric { // `byteorder::LittleEndian` already yields the correct host-native // integer regardless of target endianness, so `low_part`/`high_part` - // need no further swapping (a previous `cfg(target_endian = "big")` - // swap here corrupted large decimals on big-endian hosts). + // need no further swapping. let high_part = high_part * (u64::MAX as u128 + 1); low_part + high_part }