diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ff7c76a..af3f5ce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -231,7 +231,8 @@ copies `kokoro-server` next to `libkokoro`; `build.rs` links against the library - `AppState` (`routes.rs`) keeps the engine behind a `tokio::sync::Mutex`. A request waits for the lock without a thread, then synthesizes in `spawn_blocking`, which also reads the thread-local `kokoro_last_error()`. - `POST /v1/audio/speech` parses the JSON body (`speech_request.rs`), then encodes the float samples as 16-bit PCM - WAV or raw PCM (`audio_encoding.rs`). Other formats get an OpenAI-style 400 error. + WAV or raw PCM (`audio_encoding.rs`). Other formats get an OpenAI-style 400 error. `input` and `voice` must not + contain a NUL byte (C strings would silently truncate it); such requests get a 400 naming the field. - `GET /v1/audio/voices` lists the loaded voices; `GET /health` answers `{"status":"ok"}`. - The listen socket (`listener.rs`) sets `SO_REUSEADDR` (`SO_EXCLUSIVEADDRUSE` on Windows) and never `SO_REUSEPORT`, so a second server on the same port fails instead of sharing it. diff --git a/src/server/engine.rs b/src/server/engine.rs index 83d4b15..90a1da9 100644 --- a/src/server/engine.rs +++ b/src/server/engine.rs @@ -108,9 +108,15 @@ impl Engine { } pub fn synthesize(&mut self, text: &str, voice: &str, speed: f32) -> Result { - // C strings end at the first NUL, as with the C++ server's c_str(). - let c_prefix = |s: &str| CString::new(s.split('\0').next().unwrap_or_default()).expect("prefix has no NUL"); - let (text, voice) = (c_prefix(text), c_prefix(voice)); + // Reject rather than truncate: a NUL would silently end the string at the C boundary, losing the tail. + let c_arg = |text: &str| { + c_string(text).map_err(|message| SynthesisError { + status: kokoro_status::ERROR_INVALID_ARGUMENT, + message, + }) + }; + let text = c_arg(text)?; + let voice = c_arg(voice)?; let mut audio = Audio(kokoro_audio { samples: ptr::null_mut(), num_samples: 0, sample_rate: 0 }); // SAFETY: self.ctx is live and exclusively borrowed; strings outlive the call; audio.0 is a valid out value. let status = unsafe { ffi::kokoro_synthesize(self.ctx, text.as_ptr(), voice.as_ptr(), speed, 0, &mut audio.0) }; diff --git a/src/server/speech_request.rs b/src/server/speech_request.rs index 568f632..43ab03e 100644 --- a/src/server/speech_request.rs +++ b/src/server/speech_request.rs @@ -53,19 +53,30 @@ fn is_blank(text: &str) -> bool { text.bytes().all(|b| b == b' ' || (b'\t'..=b'\r').contains(&b)) } -fn parse_input(body: &Body) -> Result { - match optional_member(body, "input").and_then(Value::as_str) { - Some(text) if !is_blank(text) => Ok(text.to_owned()), - _ => fail("'input' must be a non-empty string", Some("input")), +// C strings end at the first NUL byte, so a NUL would silently truncate the value at the engine boundary. +fn check_no_nul(text: &str, field: &'static str) -> Result<(), RequestError> { + if text.contains('\0') { + fail(format!("'{}' must not contain a NUL byte", field), Some(field)) + } else { + Ok(()) } } +fn parse_input(body: &Body) -> Result { + let text = match optional_member(body, "input").and_then(Value::as_str) { + Some(text) if !is_blank(text) => text, + _ => return fail("'input' must be a non-empty string", Some("input")), + }; + check_no_nul(text, "input").map(|()| text.to_owned()) +} + fn parse_voice(body: &Body, default_voice: &str) -> Result { - match optional_member(body, "voice") { - None => Ok(default_voice.to_owned()), - Some(Value::String(voice)) => Ok(voice.clone()), - Some(_) => fail("'voice' must be a string", Some("voice")), - } + let voice = match optional_member(body, "voice") { + None => return Ok(default_voice.to_owned()), + Some(Value::String(voice)) => voice, + Some(_) => return fail("'voice' must be a string", Some("voice")), + }; + check_no_nul(voice, "voice").map(|()| voice.clone()) } fn parse_speed(body: &Body) -> Result { @@ -138,6 +149,16 @@ mod tests { assert_eq!(request.format, ResponseFormat::Wav); } + #[test] + fn nul_bytes_are_rejected_instead_of_truncated() { + let input = rejected(r#"{"input":"Hello\u0000world"}"#); + assert_eq!(input.message, "'input' must not contain a NUL byte"); + assert_eq!(input.param, Some("input")); + let voice = rejected(r#"{"input":"x","voice":"af_maple\u0000"}"#); + assert_eq!(voice.message, "'voice' must not contain a NUL byte"); + assert_eq!(voice.param, Some("voice")); + } + #[test] fn accepted_values() { let request = diff --git a/src/server/tests/server_synthesis.rs b/src/server/tests/server_synthesis.rs index 03ed160..8122923 100644 --- a/src/server/tests/server_synthesis.rs +++ b/src/server/tests/server_synthesis.rs @@ -74,6 +74,14 @@ fn server_synthesizes_audio() { assert_eq!((pcm.status, pcm.content_type.as_str()), (200, "audio/pcm")); assert!(!pcm.body.is_empty() && pcm.body.len() % 2 == 0); + // A NUL byte must produce a 400 naming the field, never 200 with truncated audio. + let nul_input = post(&agent, &url, r#"{"input":"Hello\u0000world"}"#); + assert_eq!(nul_input.status, 400); + assert_eq!(json(&nul_input)["error"]["param"], "input"); + let nul_voice = post(&agent, &url, r#"{"input":"Hello world","voice":"af_maple\u0000"}"#); + assert_eq!(nul_voice.status, 400); + assert_eq!(json(&nul_voice)["error"]["param"], "voice"); + let voice = post(&agent, &url, r#"{"input":"Hello world","voice":"nope"}"#); assert_eq!(voice.status, 400); assert_eq!(json(&voice)["error"]["param"], "voice");