From 43f9c9ebf5514617a3f8fe3c6f491f3e6228dd7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:13:45 +0000 Subject: [PATCH 1/9] feat(api): serve naruon POSTs on loopback with a live deadline PR #87 accepted analysis-run bodies with knowledge_cutoff "k" and hung when a client sent a partial request. The named live listener now installs a read/write deadline, requires a loopback Host, refuses Transfer-Encoding and NIM/proxy headers, parses RFC 3339 cutoffs, keys idempotency by tenant plus key, and proves export over TCP. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + Cargo.lock | 2 + crates/tepp_api/Cargo.toml | 4 +- crates/tepp_api/src/analysis_run.rs | 33 +- crates/tepp_api/src/lib.rs | 16 +- crates/tepp_api/src/naruon_http.rs | 44 +- crates/tepp_api/src/naruon_live.rs | 653 +++++++++++++++++ crates/tepp_api/tests/naruon_http_contract.rs | 16 + .../tests/naruon_live_http_contract.rs | 672 ++++++++++++++++++ docs/API_CONTRACT.md | 4 +- docs/TRACEABILITY.md | 2 +- .../0011-standalone-modular-msa-boundary.md | 2 +- docs/adr/README.md | 2 +- docs/connectors/naruon-artifact-consumer.md | 13 +- .../HOURLY_NIM_PRODUCT_DEVELOPMENT.md | 12 + docs/research/naruon-http-interchange.md | 44 +- docs/research/standards-and-literature.md | 8 + .../task-12-versioned-api-contracts.md | 6 +- docs/validation/temporal-event-foundation.md | 2 +- 19 files changed, 1490 insertions(+), 46 deletions(-) create mode 100644 crates/tepp_api/src/naruon_live.rs create mode 100644 crates/tepp_api/tests/naruon_live_http_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cc6e879..96210c597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/Cargo.lock b/Cargo.lock index 372a55f43..4c89a2525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1270,8 +1270,10 @@ dependencies = [ name = "tepp_api" version = "0.1.0" dependencies = [ + "jiff", "serde", "serde_json", + "temporal_core", ] [[package]] diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 6768ea18e..a7dae73c7 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tepp_api" -description = "Versioned service DTOs, schemas, and export contracts." +description = "Versioned service DTOs, schemas, export contracts, and loopback naruon HTTP." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -14,8 +14,10 @@ categories.workspace = true publish = false [dependencies] +jiff = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +temporal_core = { path = "../temporal_core" } [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 16ac6ba80..b9616e624 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -4,7 +4,9 @@ use crate::ApiError; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; +use jiff::Timestamp; use serde::{Deserialize, Serialize}; +use temporal_core::KnowledgeCutoff; /// Supported analysis-run contract version. pub const ANALYSIS_RUN_CONTRACT_VERSION: u16 = 1; @@ -83,13 +85,29 @@ impl AnalysisRunRequest { require_nonempty(&self.idempotency_key)?; require_nonempty(&self.tenant_workspace_id)?; require_nonempty(&self.snapshot_id)?; - require_nonempty(&self.knowledge_cutoff)?; + require_rfc3339_knowledge_cutoff(&self.knowledge_cutoff)?; require_nonempty(&self.model_contract_version)?; require_nonempty(&self.output_profile)?; Ok(()) } } +/// Parse `knowledge_cutoff` as a TEPP clock and refuse a cutoff after now. +/// +/// A buyer cannot claim analysis of evidence that is not yet available. The +/// request receipt instant is treated as availability of the command itself. +fn require_rfc3339_knowledge_cutoff(knowledge_cutoff: &str) -> Result<(), ApiError> { + require_nonempty(knowledge_cutoff)?; + let cutoff = KnowledgeCutoff::parse_rfc3339(knowledge_cutoff) + .map_err(|_| ApiError::InvalidWirePayload)?; + let receipt = KnowledgeCutoff::parse_rfc3339(&Timestamp::now().to_string()) + .map_err(|_| ApiError::InvalidWirePayload)?; + if cutoff.instant() > receipt.instant() { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + impl AnalysisRunAccepted { /// Construct a validated accepted-run response. /// @@ -223,6 +241,19 @@ mod tests { bad.output_profile.clear(); assert_eq!(bad.to_json(), Err(ApiError::InvalidWirePayload)); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2099-01-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + let accepted = AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("acc"); let accepted_json = accepted.to_json().expect("aj"); assert_eq!( diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b675a818a..21de659d1 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -6,8 +6,9 @@ //! component without sharing application tables. Domain estimation remains in //! scientific crates; this crate only defines fail-closed interchange shapes. //! naruon HTTP interchange is a versioned `https` POST to analysis-run and -//! export paths; table-access URLs, review/Copilot headers, and lexical -//! inference claims fail closed (ADR 0011). +//! export paths; table-access URLs, review/Copilot/NIM/proxy headers, and +//! lexical inference claims fail closed. A loopback live listener proves +//! those POSTs over TCP without claiming production TLS (ADR 0011). mod analysis_run; mod authorization; @@ -15,6 +16,7 @@ mod envelope; mod error; mod export; mod naruon_http; +mod naruon_live; mod wire; /// Analysis-run contract version constant. @@ -66,3 +68,13 @@ pub use naruon_http::naruon_analysis_run_exchange_with_headers; pub use naruon_http::naruon_export_exchange; /// Refuse lexical heuristics as TEPP inference claims. pub use naruon_http::naruon_may_claim_tepp_inference; +/// Maximum live HTTP header-block bytes. +pub use naruon_live::NARUON_LIVE_HEADER_BYTE_LIMIT; +/// Maximum live HTTP header count. +pub use naruon_live::NARUON_LIVE_HEADER_COUNT_LIMIT; +/// Accepted-stream read/write deadline. +pub use naruon_live::NARUON_LIVE_IO_TIMEOUT; +/// HTTP/1.1 response from the naruon live listener. +pub use naruon_live::NaruonLiveResponse; +/// Loopback live HTTP/1.1 service for naruon POSTs. +pub use naruon_live::NaruonLiveService; diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 2d2d0083c..b884d76d8 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -142,22 +142,34 @@ fn compose_https_target(origin: &str, path: &str) -> Result { Ok(format!("{origin}{path}")) } +/// Return whether `name` is a reserved naruon interchange header. +pub(crate) fn header_is_reserved_standard(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "content-type" | "tepp-consumer" | "tepp-contract-version" | "idempotency-key" + ) +} + +/// Return whether `name` is a review, model, proxy, or bearer credential header. +pub(crate) fn header_is_credential(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + lowered == "authorization" + || lowered == "proxy-authorization" + || lowered == "cookie" + || lowered == "x-api-key" + || lowered.contains("token") + || lowered.contains("copilot") + || lowered.contains("github") + || lowered.contains("nim") + || lowered.contains("nvidia") +} + fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiError> { for (name, _) in extra_headers { - let lowered = name.to_ascii_lowercase(); - if matches!( - lowered.as_str(), - "content-type" | "tepp-consumer" | "tepp-contract-version" | "idempotency-key" - ) { + if header_is_reserved_standard(name) { return Err(ApiError::InvalidWirePayload); } - if lowered == "authorization" - || lowered == "cookie" - || lowered == "x-api-key" - || lowered.contains("token") - || lowered.contains("copilot") - || lowered.contains("github") - { + if header_is_credential(name) { return Err(ApiError::AuthorizationDenied); } } @@ -298,6 +310,14 @@ mod tests { refuse_credential_headers(&[("x-copilot-session", "t")]), Err(ApiError::AuthorizationDenied) ); + assert_eq!( + refuse_credential_headers(&[("Proxy-Authorization", "Basic x")]), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + refuse_credential_headers(&[("x-nvidia-nim-key", "nvapi-x")]), + Err(ApiError::AuthorizationDenied) + ); } #[test] diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs new file mode 100644 index 000000000..fd4100eee --- /dev/null +++ b/crates/tepp_api/src/naruon_live.rs @@ -0,0 +1,653 @@ +//! Loopback-only live HTTP/1.1 listener for naruon modular POSTs (ADR 0011). + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; + +use crate::authorization::{ + AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, +}; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, header_is_credential}; +use crate::wire::{from_json, to_json}; +use crate::{ + AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, + ErrorEnvelope, requests_are_idempotent_matches, +}; + +/// Maximum request-line plus header bytes accepted before the body. +pub const NARUON_LIVE_HEADER_BYTE_LIMIT: usize = 8 * 1024; + +/// Maximum number of HTTP header lines on one live request. +pub const NARUON_LIVE_HEADER_COUNT_LIMIT: usize = 32; + +/// Read and write deadline installed on every accepted stream. +pub const NARUON_LIVE_IO_TIMEOUT: Duration = Duration::from_secs(1); + +/// HTTP/1.1 response produced by the naruon live listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NaruonLiveResponse { + /// Numeric status code. + pub status_code: u16, + /// RFC 9110 reason phrase paired with [`Self::status_code`]. + pub reason_phrase: &'static str, + /// JSON accepted-run, export-decision, or redacted error envelope. + pub body: String, +} + +/// Loopback live HTTP/1.1 service for naruon analysis-run and export POSTs. +/// +/// Production interchange origins remain `https` only. This listener binds +/// loopback TCP so tests and local standalone operation can prove request +/// handling without claiming TLS termination or cross-service table access. +/// This port only accepts versioned naruon POSTs. +#[derive(Debug)] +pub struct NaruonLiveService { + listener: Option, + bound_addr: Option, + next_run_serial: u64, + next_request_serial: u64, + accepted_runs: HashMap, +} + +impl Default for NaruonLiveService { + fn default() -> Self { + Self::new() + } +} + +impl NaruonLiveService { + /// Construct an in-memory handler with no socket. + #[must_use] + pub fn new() -> Self { + Self { + listener: None, + bound_addr: None, + next_run_serial: 1, + next_request_serial: 1, + accepted_runs: HashMap::new(), + } + } + + /// Bind `127.0.0.1:0`. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the operating system + /// refuses the loopback bind. + pub fn bind_loopback() -> Result { + Self::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + } + + /// Bind a caller-supplied address after refusing non-loopback IPs. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback bind + /// address and [`ApiError::InvalidWirePayload`] when the socket cannot + /// be opened. + pub fn bind(addr: SocketAddr) -> Result { + if !addr.ip().is_loopback() { + return Err(ApiError::AuthorizationDenied); + } + let listener = TcpListener::bind(addr).map_err(|error| map_io_error(&error))?; + let bound_addr = listener + .local_addr() + .map_err(|error| map_io_error(&error))?; + let mut service = Self::new(); + service.listener = Some(listener); + service.bound_addr = Some(bound_addr); + Ok(service) + } + + /// Return the bound loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound. + pub fn local_addr(&self) -> Result { + self.bound_addr.ok_or(ApiError::InvalidWirePayload) + } + + /// Accept one TCP connection and serve one HTTP/1.1 request. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound or + /// the accept/write path fails for a non-timeout reason. Timeouts map to + /// [`ApiError::LimitExceeded`]. + pub fn serve_one(&mut self) -> Result { + let listener = self.listener.as_ref().ok_or(ApiError::InvalidWirePayload)?; + self.serve_accepted(listener.accept().map(|(stream, _)| stream)) + } + + /// Serve one already-accepted stream, or map an accept failure. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] + /// when accept or response writing fails. Request-protocol failures become + /// HTTP error responses and are returned as `Ok`. + pub fn serve_accepted( + &mut self, + accepted: Result, + ) -> Result { + let mut stream = accepted.map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + let response = match Self::read_http_request(&mut stream) { + Ok(request) => self.handle_http_request(&request), + Err(error) => self.response_from_error(error), + }; + Self::write_response(&mut stream, &response)?; + Ok(response) + } + + /// Parse and handle a complete HTTP/1.1 request already in memory. + #[must_use] + pub fn handle_http_request(&mut self, request: &str) -> NaruonLiveResponse { + match self.dispatch_http_request(request) { + Ok(response) => response, + Err(error) => self.response_from_error(error), + } + } + + /// Read one HTTP/1.1 request from `reader`, including the declared body. + /// + /// # Errors + /// + /// Returns [`ApiError::LimitExceeded`] on timeout or when headers exceed + /// [`NARUON_LIVE_HEADER_BYTE_LIMIT`]. Other read/framing failures are + /// [`ApiError::InvalidWirePayload`]. + pub fn read_http_request(reader: &mut R) -> Result { + let mut header_bytes = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + if header_bytes.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let read = reader + .read(&mut byte) + .map_err(|error| map_io_error(&error))?; + if read == 0 { + return Err(ApiError::InvalidWirePayload); + } + header_bytes.push(byte[0]); + if header_bytes.ends_with(b"\r\n\r\n") { + break; + } + } + let header_text = + std::str::from_utf8(&header_bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let content_length = declared_content_length(header_text)?; + if content_length > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut body = vec![0_u8; content_length]; + if content_length > 0 { + reader + .read_exact(&mut body) + .map_err(|error| map_io_error(&error))?; + } + let body_text = std::str::from_utf8(&body).map_err(|_| ApiError::InvalidWirePayload)?; + Ok(format!("{header_text}{body_text}")) + } + + /// Write one HTTP/1.1 response to `writer`. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the write fails. + pub fn write_response( + writer: &mut W, + response: &NaruonLiveResponse, + ) -> Result<(), ApiError> { + writer + .write_all(&response.to_http_bytes()) + .map_err(|error| map_io_error(&error))?; + writer.flush().map_err(|error| map_io_error(&error)) + } + + fn dispatch_http_request(&mut self, request: &str) -> Result { + let (header_block, body) = split_request(request)?; + let mut lines = header_block.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let (method, path) = parse_request_line(request_line)?; + if method != "POST" { + return Err(ApiError::InvalidWirePayload); + } + if path != NARUON_ANALYSIS_RUN_PATH && path != NARUON_EXPORT_PATH { + return Err(ApiError::InvalidWirePayload); + } + let headers = parse_headers(lines)?; + refuse_live_headers(&headers, self.bound_addr)?; + self.dispatch_path(path, &headers, body) + } + + fn dispatch_path( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if path == NARUON_ANALYSIS_RUN_PATH { + self.accept_analysis_run(headers, body) + } else { + Self::authorize_export(headers, body) + } + } + + fn accept_analysis_run( + &mut self, + headers: &HashMap, + body: &str, + ) -> Result { + let request = AnalysisRunRequest::from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != request.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = tenant_idempotency_key(&request.tenant_workspace_id, idempotency_key); + if let Some((stored_request, stored_accepted)) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(stored_request, &request) { + return Ok(NaruonLiveResponse::json( + 202, + "Accepted", + stored_accepted.to_json()?, + )); + } + return Err(ApiError::InvalidWirePayload); + } + let run_id = format!("naruon-run-{}", self.next_run_serial); + self.next_run_serial += 1; + let accepted = + AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; + let body = accepted.to_json()?; + self.accepted_runs.insert(replay_key, (request, accepted)); + Ok(NaruonLiveResponse::json(202, "Accepted", body)) + } + + fn authorize_export( + headers: &HashMap, + body: &str, + ) -> Result { + let request: ExportAuthorizationRequest = from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key == request.principal_id { + return Err(ApiError::InvalidWirePayload); + } + if request.purpose != AnalyticalPurpose::ModularServiceConsumer { + return Err(ApiError::AuthorizationDenied); + } + let decision = authorize_export(&request)?; + require_export_allowed(&decision)?; + Ok(NaruonLiveResponse::json(200, "OK", to_json(&decision)?)) + } + + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { + let request_id = format!("naruon-live-{}", self.next_request_serial); + self.next_request_serial += 1; + let (status_code, reason_phrase) = status_for(error); + NaruonLiveResponse::json(status_code, reason_phrase, envelope_json(error, request_id)) + } +} + +impl NaruonLiveResponse { + fn json(status_code: u16, reason_phrase: &'static str, body: String) -> Self { + Self { + status_code, + reason_phrase, + body, + } + } + + /// Render the response as an HTTP/1.1 message. + #[must_use] + pub fn to_http_bytes(&self) -> Vec { + format!( + "HTTP/1.1 {} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + self.status_code, + self.reason_phrase, + self.body.len(), + self.body + ) + .into_bytes() + } +} + +fn tenant_idempotency_key(tenant_workspace_id: &str, idempotency_key: &str) -> String { + format!("{tenant_workspace_id}\u{1f}{idempotency_key}") +} + +fn envelope_json(error: ApiError, request_id: String) -> String { + ErrorEnvelope::from_api_error(error, request_id) + .and_then(|envelope| envelope.to_json()) + .unwrap_or_else(|_| fallback_envelope_json()) +} + +fn fallback_envelope_json() -> String { + "{\"error_code\":\"invalid_wire_payload\",\"message\":\"invalid API wire payload\",\"request_id\":\"naruon-live-fallback\",\"retryable\":false}".to_owned() +} + +fn status_for(error: ApiError) -> (u16, &'static str) { + match error { + ApiError::InvalidWirePayload => (400, "Bad Request"), + ApiError::AuthorizationDenied => (403, "Forbidden"), + ApiError::LimitExceeded => (413, "Payload Too Large"), + ApiError::UnsupportedContractVersion => (422, "Unprocessable Entity"), + } +} + +fn map_io_error(error: &std::io::Error) -> ApiError { + match error.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ApiError::LimitExceeded, + _ => ApiError::InvalidWirePayload, + } +} + +fn split_request(request: &str) -> Result<(&str, &str), ApiError> { + let Some(index) = request.find("\r\n\r\n") else { + if request.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + return Err(ApiError::InvalidWirePayload); + }; + if index > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let header_block = &request[..index]; + let body = &request[index + 4..]; + let declared = declared_content_length(&format!("{header_block}\r\n\r\n"))?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + if declared > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + Ok((header_block, body)) +} + +fn declared_content_length(header_text: &str) -> Result { + let header_block = header_text + .strip_suffix("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut found = None; + for line in header_block.split("\r\n").skip(1) { + let (name, value) = split_header_line(line)?; + if name.eq_ignore_ascii_case("content-length") { + if found.is_some() { + return Err(ApiError::InvalidWirePayload); + } + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ApiError::InvalidWirePayload); + } + found = Some(value.parse().map_err(|_| ApiError::InvalidWirePayload)?); + } + } + found.ok_or(ApiError::InvalidWirePayload) +} + +fn parse_request_line(line: &str) -> Result<(&str, &str), ApiError> { + let mut parts = line.split(' '); + let method = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let path = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let version = parts.next().ok_or(ApiError::InvalidWirePayload)?; + if parts.next().is_some() || version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + if !path.starts_with('/') || path.contains('?') || path.contains('#') || path.contains("://") { + return Err(ApiError::InvalidWirePayload); + } + Ok((method, path)) +} + +fn parse_headers<'a, I>(lines: I) -> Result, ApiError> +where + I: Iterator, +{ + let mut headers = HashMap::new(); + let mut count = 0_usize; + for line in lines { + count += 1; + if count > NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = split_header_line(line)?; + let key = name.to_ascii_lowercase(); + if headers.contains_key(&key) { + return Err(ApiError::InvalidWirePayload); + } + headers.insert(key, value.to_owned()); + } + Ok(headers) +} + +fn split_header_line(line: &str) -> Result<(&str, &str), ApiError> { + let Some((name, value)) = line.split_once(':') else { + return Err(ApiError::InvalidWirePayload); + }; + if name.is_empty() || name.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(ApiError::InvalidWirePayload); + } + Ok((name, value.trim())) +} + +fn refuse_live_headers( + headers: &HashMap, + bound_addr: Option, +) -> Result<(), ApiError> { + for name in headers.keys() { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + } + if headers.contains_key("transfer-encoding") { + return Err(ApiError::InvalidWirePayload); + } + let host = header_value(headers, "host")?; + if host_implies_table_access(host) { + return Err(ApiError::InvalidWirePayload); + } + if !host_is_loopback(host, bound_addr) { + return Err(ApiError::AuthorizationDenied); + } + if header_value(headers, "content-type")? != "application/json" { + return Err(ApiError::InvalidWirePayload); + } + if header_value(headers, "tepp-consumer")? != "naruon" { + return Err(ApiError::InvalidWirePayload); + } + if header_value(headers, "tepp-contract-version")? != "1" { + return Err(ApiError::InvalidWirePayload); + } + let _idempotency_key = header_value(headers, "idempotency-key")?; + Ok(()) +} + +fn header_value<'a>(headers: &'a HashMap, name: &str) -> Result<&'a str, ApiError> { + let value = headers.get(name).ok_or(ApiError::InvalidWirePayload)?; + if value.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + Ok(value.as_str()) +} + +fn host_implies_table_access(host: &str) -> bool { + let lowered = host.to_ascii_lowercase(); + lowered.contains("postgres") + || lowered.contains("jdbc") + || lowered.contains("/sql") + || lowered.contains("/tables/") + || lowered.contains('\'') + || lowered.contains(';') + || lowered.contains('\\') + || lowered.contains(' ') + || lowered.chars().any(char::is_control) +} + +fn host_is_loopback(host: &str, bound_addr: Option) -> bool { + if let Some(bound) = bound_addr + && (host == bound.to_string() || host == bound.ip().to_string()) + { + return true; + } + if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().starts_with("localhost:") + { + return true; + } + if let Ok(addr) = host.parse::() { + return addr.ip().is_loopback(); + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +#[cfg(test)] +mod tests { + use super::{ + NaruonLiveService, declared_content_length, envelope_json, fallback_envelope_json, + host_implies_table_access, host_is_loopback, map_io_error, parse_request_line, + split_header_line, split_request, status_for, tenant_idempotency_key, + }; + use crate::ApiError; + use std::io::ErrorKind; + use std::net::SocketAddr; + + #[test] + fn helpers_cover_status_io_host_and_request_line_edges() { + assert_eq!( + status_for(ApiError::InvalidWirePayload), + (400, "Bad Request") + ); + assert_eq!( + status_for(ApiError::AuthorizationDenied), + (403, "Forbidden") + ); + assert_eq!( + status_for(ApiError::LimitExceeded), + (413, "Payload Too Large") + ); + assert_eq!( + status_for(ApiError::UnsupportedContractVersion), + (422, "Unprocessable Entity") + ); + assert_eq!( + map_io_error(&std::io::Error::new(ErrorKind::TimedOut, "t")), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::new(ErrorKind::WouldBlock, "w")), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::other("x")), + ApiError::InvalidWirePayload + ); + assert!(host_implies_table_access("db.postgres.local")); + assert!(host_implies_table_access("jdbc.local")); + assert!(host_implies_table_access("127.0.0.1/sql")); + assert!(host_implies_table_access("127.0.0.1/tables/x")); + assert!(host_implies_table_access("bad host")); + assert!(host_implies_table_access("bad;host")); + assert!(host_implies_table_access("bad'host")); + assert!(host_implies_table_access("bad\\host")); + assert!(host_implies_table_access("bad\u{0001}host")); + assert!(!host_implies_table_access("127.0.0.1:43789")); + assert!(host_is_loopback("127.0.0.1", None)); + assert!(host_is_loopback("localhost", None)); + assert!(host_is_loopback("localhost:8080", None)); + assert!(host_is_loopback("[::1]:9", None)); + assert!(host_is_loopback("::1", None)); + assert!(!host_is_loopback("8.8.8.8", None)); + assert!(!host_is_loopback("attacker.example.com", None)); + let bound: SocketAddr = "127.0.0.1:43789".parse().expect("bound"); + assert!(host_is_loopback("127.0.0.1:43789", Some(bound))); + assert!(host_is_loopback("127.0.0.1", Some(bound))); + assert_eq!( + tenant_idempotency_key("tenant-a", "idem-1"), + "tenant-a\u{1f}idem-1" + ); + } + + #[test] + fn helpers_cover_request_line_headers_and_accept_failure() { + assert_eq!( + parse_request_line("POST /v1/analysis-runs HTTP/1.1 extra"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST https://tepp.example/v1/analysis-runs HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /v1/analysis-runs#x HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /only"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("NoColon"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line(": empty-name"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Bad Name: v"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Host: 127.0.0.1").expect("hdr"), + ("Host", "127.0.0.1") + ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 1\r\ncontent-length: 1\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: +1\r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_request(&"x".repeat(super::NARUON_LIVE_HEADER_BYTE_LIMIT)), + Err(ApiError::LimitExceeded) + ); + assert!(!fallback_envelope_json().is_empty()); + assert!( + envelope_json(ApiError::InvalidWirePayload, String::new()) + .contains("naruon-live-fallback") + ); + assert!(envelope_json(ApiError::LimitExceeded, "req-1".into()).contains("limit_exceeded")); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 999999999999999999999\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + NaruonLiveService::new() + .serve_accepted(Err(std::io::Error::other("accept"))) + .expect_err("accept"), + ApiError::InvalidWirePayload + ); + } +} diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index 143277126..b3a711c22 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -92,6 +92,22 @@ fn review_and_copilot_headers_are_authorization_denied() { ), Err(ApiError::AuthorizationDenied) ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("Proxy-Authorization", "Basic review-agent")] + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("x-nvidia-nim-key", "nvapi-example")] + ), + Err(ApiError::AuthorizationDenied) + ); } #[test] diff --git a/crates/tepp_api/tests/naruon_live_http_contract.rs b/crates/tepp_api/tests/naruon_live_http_contract.rs new file mode 100644 index 000000000..f2b8f4779 --- /dev/null +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -0,0 +1,672 @@ +//! Live loopback HTTP/1.1 naruon POSTs stay versioned and fail closed (ADR 0011). + +use std::fmt::Write as _; +use std::io::{Cursor, Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::thread; +use std::time::{Duration, Instant}; + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, + ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, + NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, NaruonLiveService, + naruon_analysis_run_exchange, naruon_export_exchange, +}; + +fn sample_run() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-live-idem-001".into(), + tenant_workspace_id: "naruon-tenant-workspace-demo".into(), + snapshot_id: "tepp-snapshot-demo-001".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "topic-measurement-v1".into(), + output_profile: "naruon-consumer-validation-report".into(), + } +} + +fn sample_export() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "naruon-tenant-workspace-demo".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "tepp-export-demo-001".into(), + includes_source_text: false, + } +} + +fn naruon_headers(idempotency_key: &str) -> Vec<(String, String)> { + vec![ + ("Host".into(), "127.0.0.1".into()), + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ("idempotency-key".into(), idempotency_key.to_owned()), + ] +} + +fn http_request(method: &str, path: &str, headers: &[(String, String)], body: &str) -> String { + let mut request = format!("{method} {path} HTTP/1.1\r\n"); + for (name, value) in headers { + write!(request, "{name}: {value}\r\n").expect("header"); + } + write!(request, "content-length: {}\r\n\r\n{body}", body.len()).expect("len"); + request +} + +fn analysis_http(run: &AnalysisRunRequest) -> String { + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + &run.to_json().expect("run json"), + ) +} + +fn export_http(request: &ExportAuthorizationRequest, idempotency_key: &str) -> String { + http_request( + "POST", + NARUON_EXPORT_PATH, + &naruon_headers(idempotency_key), + &serde_json::to_string(request).expect("export json"), + ) +} + +fn envelope(body: &str) -> ErrorEnvelope { + serde_json::from_str(body).expect("error envelope") +} + +#[test] +fn loopback_bind_refuses_non_loopback_and_in_use_ports() { + assert_eq!( + NaruonLiveService::bind("0.0.0.0:0".parse::().expect("unspec")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + assert_eq!( + NaruonLiveService::bind("8.8.8.8:0".parse::().expect("public")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + assert_eq!( + NaruonLiveService::bind("[::]:0".parse::().expect("v6-unspec")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + let first = NaruonLiveService::bind_loopback().expect("first bind"); + let addr = first.local_addr().expect("addr"); + assert!(addr.ip().is_loopback()); + assert_eq!( + NaruonLiveService::bind(addr).expect_err("in use"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::new().local_addr().expect_err("no sock"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::new().serve_one().expect_err("no sock"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::default() + .serve_one() + .expect_err("default"), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn handle_http_accepts_analysis_run_and_replays_idempotent_retries() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let first = service.handle_http_request(&analysis_http(&run)); + assert_eq!(first.status_code, 202); + assert_eq!(first.reason_phrase, "Accepted"); + let accepted = AnalysisRunAccepted::from_json(&first.body).expect("accepted"); + assert_eq!(accepted.idempotency_key, run.idempotency_key); + assert_eq!(accepted.run_state, "accepted"); + assert!(!accepted.run_id.is_empty()); + + let replay = service.handle_http_request(&analysis_http(&run)); + assert_eq!(replay.status_code, 202); + assert_eq!(replay.body, first.body); + + let mut conflicting = run.clone(); + conflicting.snapshot_id = "other-snapshot".into(); + let conflict = service.handle_http_request(&analysis_http(&conflicting)); + assert_eq!(conflict.status_code, 400); + assert_eq!( + envelope(&conflict.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_keys_idempotency_replay_by_tenant_and_key() { + let mut service = NaruonLiveService::new(); + let first = sample_run(); + let mut second = first.clone(); + second.tenant_workspace_id = "naruon-tenant-workspace-other".into(); + let a = service.handle_http_request(&analysis_http(&first)); + let b = service.handle_http_request(&analysis_http(&second)); + assert_eq!(a.status_code, 202); + assert_eq!(b.status_code, 202); + let accepted_a = AnalysisRunAccepted::from_json(&a.body).expect("a"); + let accepted_b = AnalysisRunAccepted::from_json(&b.body).expect("b"); + assert_ne!(accepted_a.run_id, accepted_b.run_id); +} + +#[test] +fn handle_http_authorizes_modular_export_and_refuses_other_purposes() { + let mut service = NaruonLiveService::new(); + let allowed = sample_export(); + let ok = service.handle_http_request(&export_http(&allowed, "export-op-a")); + assert_eq!(ok.status_code, 200); + assert_eq!(ok.reason_phrase, "OK"); + assert!(ok.body.contains("purpose_bound_export_allowed")); + assert!(!ok.body.contains("token")); + + let denied = ExportAuthorizationRequest { + purpose: AnalyticalPurpose::OperationalMonitoring, + ..allowed.clone() + }; + let forbidden = service.handle_http_request(&export_http(&denied, "export-op-b")); + assert_eq!(forbidden.status_code, 403); + assert_eq!( + envelope(&forbidden.body).error_code(), + "authorization_denied" + ); + + let same_as_principal = + service.handle_http_request(&export_http(&allowed, allowed.principal_id.as_str())); + assert_eq!(same_as_principal.status_code, 400); + assert_eq!( + envelope(&same_as_principal.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_refuses_methods_paths_versions_and_table_hosts() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + let headers = naruon_headers(&run.idempotency_key); + + let get = service.handle_http_request(&http_request( + "GET", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(get.status_code, 400); + + let unknown = service.handle_http_request(&http_request( + "POST", + "/v1/tables/document_record", + &headers, + &body, + )); + assert_eq!(unknown.status_code, 400); + + let sql = service.handle_http_request(&http_request("POST", "/sql", &headers, &body)); + assert_eq!(sql.status_code, 400); + + let query = service.handle_http_request(&http_request( + "POST", + "/v1/analysis-runs?drop=1", + &headers, + &body, + )); + assert_eq!(query.status_code, 400); + + let http10 = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ); + assert_eq!(service.handle_http_request(&http10).status_code, 400); + + let mut postgres_host = headers.clone(); + postgres_host[0] = ("Host".into(), "postgres.example.test".into()); + let table_host = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &postgres_host, + &body, + )); + assert_eq!(table_host.status_code, 400); + + let mut jdbc_host = headers; + jdbc_host[0] = ("Host".into(), "jdbc.example.test".into()); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &jdbc_host, + &body + )) + .status_code, + 400 + ); +} + +#[test] +fn handle_http_requires_loopback_host_and_refuses_transfer_encoding() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + for host in ["attacker.example.com", "mysql.internal", "8.8.8.8"] { + let mut headers = naruon_headers(&run.idempotency_key); + headers[0] = ("Host".into(), host.into()); + let response = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(response.status_code, 403, "host={host}"); + assert_eq!( + envelope(&response.body).error_code(), + "authorization_denied" + ); + } + + let mut chunked = naruon_headers(&run.idempotency_key); + chunked.push(("Transfer-Encoding".into(), "chunked".into())); + let transfer = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &chunked, + &body, + )); + assert_eq!(transfer.status_code, 400); + assert_eq!( + envelope(&transfer.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_refuses_credential_headers_and_reserved_overrides() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + for (name, value, status, code) in [ + ( + "Authorization", + "Bearer review-agent", + 403, + "authorization_denied", + ), + ("cookie", "a=b", 403, "authorization_denied"), + ("x-api-key", "k", 403, "authorization_denied"), + ("x-github-token", "t", 403, "authorization_denied"), + ("x-copilot-session", "s", 403, "authorization_denied"), + ( + "Proxy-Authorization", + "Basic review-agent", + 403, + "authorization_denied", + ), + ( + "x-nvidia-nim-key", + "nvapi-example", + 403, + "authorization_denied", + ), + ("content-type", "text/plain", 400, "invalid_wire_payload"), + ("tepp-consumer", "hostile", 400, "invalid_wire_payload"), + ("tepp-contract-version", "0", 400, "invalid_wire_payload"), + ("idempotency-key", "", 400, "invalid_wire_payload"), + ] { + let mut headers = naruon_headers(&run.idempotency_key); + if name.eq_ignore_ascii_case("content-type") + || name.eq_ignore_ascii_case("tepp-consumer") + || name.eq_ignore_ascii_case("tepp-contract-version") + || name.eq_ignore_ascii_case("idempotency-key") + { + headers.retain(|(existing, _)| !existing.eq_ignore_ascii_case(name)); + } + headers.push((name.into(), value.into())); + let response = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(response.status_code, status, "header={name}"); + assert_eq!(envelope(&response.body).error_code(), code, "header={name}"); + assert!(!response.body.contains("Bearer")); + assert!(!response.body.contains("ghs_")); + assert!(!response.body.contains("nvapi-")); + } +} + +#[test] +fn handle_http_maps_wire_version_and_limit_errors() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let unsupported = r#"{"contract_version":9,"idempotency_key":"naruon-live-idem-001","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"#; + let version = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + unsupported, + )); + assert_eq!(version.status_code, 422); + assert_eq!( + envelope(&version.body).error_code(), + "unsupported_contract_version" + ); + + let oversized = "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1); + let limited = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + &oversized, + )); + assert_eq!(limited.status_code, 413); + assert_eq!(envelope(&limited.body).error_code(), "limit_exceeded"); + + let not_rfc3339 = r#"{"contract_version":1,"idempotency_key":"naruon-live-idem-001","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","model_contract_version":"m","output_profile":"o"}"#; + let cutoff = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + not_rfc3339, + )); + assert_eq!(cutoff.status_code, 400); +} + +#[test] +fn handle_http_refuses_malformed_framing_and_header_limits() { + let mut service = NaruonLiveService::new(); + assert_eq!(service.handle_http_request("").status_code, 400); + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1\n\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("NOT-A-REQUEST-LINE\r\n\r\n") + .status_code, + 400 + ); + + let mut too_many = naruon_headers("idem-many"); + for index in 0..=NARUON_LIVE_HEADER_COUNT_LIMIT { + too_many.push((format!("x-extra-{index}"), "1".into())); + } + let crowded = http_request("POST", NARUON_ANALYSIS_RUN_PATH, &too_many, "{}"); + assert_eq!(service.handle_http_request(&crowded).status_code, 413); + + let huge_name = "x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT + 8); + let huge = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\n{huge_name}: 1\r\n\r\n"); + assert_eq!(service.handle_http_request(&huge).status_code, 413); + + let mismatch = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 4\r\n\r\nab" + ); + assert_eq!(service.handle_http_request(&mismatch).status_code, 400); + + let lf_header = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost 127.0.0.1\r\n\r\n"); + assert_eq!(service.handle_http_request(&lf_header).status_code, 400); + + let missing_host = http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &[ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ("idempotency-key".into(), "k".into()), + ], + "{}", + ); + assert_eq!(service.handle_http_request(&missing_host).status_code, 400); + + let header_idem_mismatch = { + let run = sample_run(); + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers("other-idem"), + &run.to_json().expect("json"), + ) + }; + assert_eq!( + service + .handle_http_request(&header_idem_mismatch) + .status_code, + 400 + ); + + let mut duplicate_host = naruon_headers("dup"); + duplicate_host.push(("Host".into(), "127.0.0.1".into())); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &duplicate_host, + "{}" + )) + .status_code, + 400 + ); +} + +#[test] +fn read_http_request_covers_transport_and_limit_errors() { + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(Vec::::new())).expect_err("eof"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut TimeoutRead).expect_err("timeout"), + ApiError::LimitExceeded + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut OtherRead).expect_err("other"), + ApiError::InvalidWirePayload + ); + let oversized = vec![b'x'; NARUON_LIVE_HEADER_BYTE_LIMIT + 1]; + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(oversized)).expect_err("limit"), + ApiError::LimitExceeded + ); + + let run = sample_run(); + let request = analysis_http(&run); + let parsed = + NaruonLiveService::read_http_request(&mut Cursor::new(request.as_bytes())).expect("read"); + assert_eq!(parsed, request); + + let mut failing = Cursor::new(Vec::::new()); + let response = NaruonLiveService::new().handle_http_request(&request); + assert_eq!( + NaruonLiveService::write_response(&mut FailingWriter, &response).expect_err("write"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::write_response(&mut FlushFailWriter, &response).expect_err("flush"), + ApiError::InvalidWirePayload + ); + NaruonLiveService::write_response(&mut failing, &response).expect("ok write"); + assert!(failing.into_inner().starts_with(b"HTTP/1.1 202")); + + let mut invalid_utf8 = b"POST /v1/analysis-runs HTTP/1.1\r\n".to_vec(); + invalid_utf8.push(0xff); + invalid_utf8.extend_from_slice(b"\r\n\r\n"); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(invalid_utf8)).expect_err("utf8"), + ApiError::InvalidWirePayload + ); + + let mut invalid_body = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 1\r\n\r\n".to_vec(); + invalid_body.push(0xff); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(invalid_body)) + .expect_err("body utf8"), + ApiError::InvalidWirePayload + ); + + let zero = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 0\r\n\r\n"; + assert!(NaruonLiveService::read_http_request(&mut Cursor::new(zero.as_slice())).is_ok()); + + let truncated = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 4\r\n\r\nab"; + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(truncated.as_slice())) + .expect_err("short body"), + ApiError::InvalidWirePayload + ); + + let huge_len = format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-length: {}\r\n\r\n", + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1 + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(huge_len.into_bytes())) + .expect_err("declared limit"), + ApiError::LimitExceeded + ); +} + +#[test] +fn serve_one_accepts_committed_naruon_exchange_over_loopback_tcp() { + let run = sample_run(); + let exchange = naruon_analysis_run_exchange("https://tepp.example.test", &run).expect("ex"); + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + + let mut stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("rt"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("wt"); + let mut headers = naruon_headers(&run.idempotency_key); + headers[0] = ("Host".into(), format!("{addr}")); + for extra in &exchange.headers { + if extra.0 == "content-type" + || extra.0 == "tepp-consumer" + || extra.0 == "tepp-contract-version" + || extra.0 == "idempotency-key" + { + continue; + } + headers.push(extra.clone()); + } + let payload = http_request("POST", NARUON_ANALYSIS_RUN_PATH, &headers, &exchange.body); + stream.write_all(payload.as_bytes()).expect("write"); + let mut received = String::new(); + stream.read_to_string(&mut received).expect("read"); + assert!(received.starts_with("HTTP/1.1 202 Accepted")); + assert!(received.contains("\"run_state\":\"accepted\"")); + let served = worker.join().expect("join").expect("serve"); + assert_eq!(served.status_code, 202); + + let mut idle_listener = NaruonLiveService::bind_loopback().expect("bind2"); + let idle_addr = idle_listener.local_addr().expect("addr2"); + let idle_worker = thread::spawn(move || idle_listener.serve_one()); + drop(TcpStream::connect(idle_addr).expect("connect2")); + let idle_response = idle_worker.join().expect("join2").expect("served closed"); + assert_eq!(idle_response.status_code, 400); +} + +#[test] +fn serve_one_authorizes_export_over_loopback_tcp() { + let request = sample_export(); + let exchange = naruon_export_exchange("https://tepp.example.test", &request, "export-tcp-001") + .expect("ex"); + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + + let mut stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("rt"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("wt"); + let mut headers = naruon_headers("export-tcp-001"); + headers[0] = ("Host".into(), format!("{addr}")); + let payload = http_request("POST", NARUON_EXPORT_PATH, &headers, &exchange.body); + stream.write_all(payload.as_bytes()).expect("write"); + let mut received = String::new(); + stream.read_to_string(&mut received).expect("read"); + assert!(received.starts_with("HTTP/1.1 200 OK")); + assert!(received.contains("purpose_bound_export_allowed")); + let served = worker.join().expect("join").expect("serve"); + assert_eq!(served.status_code, 200); +} + +#[test] +fn serve_one_maps_partial_request_timeout_to_limit_exceeded() { + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + let stream = TcpStream::connect(addr).expect("connect"); + let started = Instant::now(); + let served = worker.join().expect("join").expect("timeout mapped"); + drop(stream); + assert!(started.elapsed() >= NARUON_LIVE_IO_TIMEOUT); + assert_eq!(served.status_code, 413); + assert_eq!(envelope(&served.body).error_code(), "limit_exceeded"); +} + +struct TimeoutRead; + +impl Read for TimeoutRead { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")) + } +} + +struct OtherRead; + +impl Read for OtherRead { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("broken")) + } +} + +struct FailingWriter; + +impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("write failed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct FlushFailWriter; + +impl Write for FlushFailWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("flush failed")) + } +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index e2263ea20..060ca6d72 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,13 +1,13 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. -**Last reviewed:** 2026-08-10 +**Last reviewed:** 2026-08-16 ## 1. Authority boundary TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts, not a production HTTP service. Endpoint examples below are target interface shapes and must not be presented as deployed behavior until implemented and tested. +Current protected main exposes Rust library/domain contracts. The active PR adds a loopback HTTP/1.1 listener for naruon analysis-run and export POSTs. That listener is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` remain target interface shapes. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d97433..019a368f1 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -36,7 +36,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | | tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target | | standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; future service ports | partial | -| naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (active PR); live HTTP service remaining | partial | +| naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (PR #42 implemented-main); loopback live listener on the active PR; production TLS remaining | partial | | contextual-orchestrator interpretation port boundary | ADR 0010/0011; LLM orchestration | `docs/connectors/contextual-orchestrator-interpretation-port.md`; live port remaining | partial | | Actions registry identities bound to protected-main tree (orphan disable) | Operability; GitHub Actions REST | `scripts/actions_workflow_fleet.py` + issue #20 tests/doctoring; live disable remains operator-authorized | active-PR | | autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | future safe OpenCode/NVIDIA autonomous-development workflow | accepted-target | diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index d0572fc25..d545ee23f 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access and credential headers) implemented on the active PR (not implemented-main); live HTTP service and remaining production persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b315..46d3f49f1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,7 +16,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | ADR 0013 governs future persistence/reproducibility/split authority. | | [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | accepted-target | Controls are normative architecture; deployment/control evidence is not yet a certification claim. | | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | accepted-target | Owns direct/verify/committee/conductor selection, budget, role/topology, and ablation policy. | -| [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | +| [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; naruon loopback live HTTP is on the active PR; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, and tenant RLS implemented; full physical ERD remaining. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority; repository SBOM/provenance generator implemented, full release bundle remaining. | diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 4044b31d4..2e4f4d6c0 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,7 +1,7 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO plus HTTP interchange on the active PR; live HTTP service remaining -**Last reviewed:** 2026-08-13 +**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining +**Last reviewed:** 2026-08-16 ## Boundary @@ -25,6 +25,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | +| Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | Committed examples live under `examples/`. Schema for analysis-run requests lives under `schemas/analysis_run_request_v1.json`. @@ -39,7 +40,9 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - knowledge cutoff / availability violations → reject in TEPP domain crates; - authorization deny → `authorization_denied` envelope without policy leakage; - `postgres` / `jdbc` / `/sql` / `/tables/` or non-`https` origins → reject; -- review, Copilot, or bearer credential headers → reject; +- review, Copilot, NIM/NVIDIA, proxy-authorization, or bearer credential headers → reject; +- non-loopback `Host` or `Transfer-Encoding` on the live listener → reject; +- non-RFC 3339 or future-dated `knowledge_cutoff` → reject; - redefinition of reserved headers (`content-type`, `tepp-consumer`, `tepp-contract-version`, `idempotency-key`) via extra headers → reject; - export interchange without a nonempty per-export idempotency key → reject; @@ -47,7 +50,9 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic ## Authority sources -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol (HTTP/1.1): Semantics and content* (RFC 7231). IETF. https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. diff --git a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md index 652acfe21..a7edc9f8e 100644 --- a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md +++ b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md @@ -20,6 +20,18 @@ run may print the task contract without either credential. When a PR exists, normal review → repair → exact-head Checks → merge governance owns the hour. The scheduler does not create a competing branch. +Current executable queue while drafts remain open: + +1. Merge the predicted-versus-observed Allen coverage gate + (`prediction_contradiction` / the coverage-authority landing PR). Keep + superseded coverage drafts unmerged. +2. Next buyer-visible slice: naruon live HTTP loopback with stream deadline, + RFC 3339 cutoff, loopback `Host`, NIM/proxy header refusal, and export + over a real `TcpStream` (this PR). Keep PR #87 unmerged. +3. After that: `text_segment` SQL on migration `0006` (PR #99), then + production TLS bind (PR #100 / #90). Do not open a competing hourly + proposal until the open-PR inventory is empty. + ## Required repository configuration Configure these repository or organization values: diff --git a/docs/research/naruon-http-interchange.md b/docs/research/naruon-http-interchange.md index 090df0289..a8245ebdb 100644 --- a/docs/research/naruon-http-interchange.md +++ b/docs/research/naruon-http-interchange.md @@ -3,26 +3,31 @@ ## Scope naruon may submit analysis-run requests and request purpose-bound exports only -through versioned `https` POST paths owned by TEPP. HTTP method, path, and -header semantics for that interchange follow HTTP/1.1 (Fielding & Reschke, -2014). Fail-closed refusal of table-access URLs, review/Copilot credential -headers, reserved-header redefinition, principal-only idempotency keys, and -lexical TEPP inference claims is repository contract authority (see Internal -contract evidence), not an RFC inference rule. - -This is not a live HTTP server. Persistence remains TEPP-owned; naruon never -migrates or queries TEPP application tables. Purpose-bound export disclosure -and privacy-management readiness map to published privacy guidance (ISO/IEC, -2019; National Institute of Standards and Technology, 2020) without claiming -certification. +through versioned `https` POST paths owned by TEPP. HTTP method, path, `Host`, +and `Transfer-Encoding` semantics follow current HTTP semantics (Fielding, +Nottingham, & Reschke, 2022). Knowledge-cutoff instants use RFC 3339 +(Klyne & Newman, 2002). Fail-closed refusal of table-access URLs, +review/Copilot/NIM/proxy credential headers, reserved-header redefinition, +principal-only idempotency keys, and lexical TEPP inference claims is +repository contract authority (see Internal contract evidence), not an RFC +inference rule. + +The live listener is loopback HTTP/1.1 with an installed read/write deadline. +It is not a production TLS/`$PORT` service. Persistence remains TEPP-owned; +naruon never migrates or queries TEPP application tables. Purpose-bound export +disclosure and privacy-management readiness map to published privacy guidance +(ISO/IEC, 2019; National Institute of Standards and Technology, 2020) without +claiming certification. ## Authority ### External standards (HTTP and privacy claims only) -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol -(HTTP/1.1): Semantics and content* (RFC 7231). IETF. -https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* +(RFC 3339). IETF. https://doi.org/10.17487/RFC3339 ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — @@ -38,13 +43,18 @@ https://doi.org/10.6028/NIST.CSWP.01162020 - `docs/API_CONTRACT.md` — versioned analysis-run and export surfaces - `docs/adr/0011-standalone-modular-msa-boundary.md` — no cross-service table access - `crates/tepp_api/tests/naruon_http_contract.rs` — fail-closed interchange proofs +- `crates/tepp_api/tests/naruon_live_http_contract.rs` — loopback TCP, deadline, Host, cutoff ## Verification - committed naruon example builds `POST /v1/analysis-runs` without credentials; - `postgres` / `jdbc` / `/sql` / `/tables/` and non-`https` origins fail closed; -- review, Copilot, and bearer headers are `AuthorizationDenied`; +- review, Copilot, NIM/NVIDIA, proxy-authorization, and bearer headers are + `AuthorizationDenied`; - reserved standard headers cannot be redefined via extra headers; +- live `Host` must be loopback; `Transfer-Encoding` is refused; +- `knowledge_cutoff` must be RFC 3339 and must not be after request receipt; +- analysis-run idempotency replay is keyed by tenant plus key; - export interchange requires `ModularServiceConsumer` and a per-export - idempotency key distinct from `principal_id` alone; + idempotency key distinct from `principal_id` alone, proven over TCP; - `tfidf` / `bm25` / `keyword` cannot claim TEPP inference. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b144684..22edb3e73 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -116,6 +116,14 @@ National Institute of Standards and Technology. (n.d.). *AI risk management fram TEPP uses these sources as management/risk/readiness inputs, not as self-certification authority. ISO/IEC 42001:2023 and ISO/IEC 23894:2023 are published international standards (International Organization for Standardization, 2023a, 2023b). NIST AI RMF 1.0 remains the published framework while NIST is preparing a revision (Tabassi, 2023; National Institute of Standards and Technology, n.d.); the repository tracks the revision but does not silently treat an unpublished successor as normative. AICPA Trust Services Criteria are readiness inputs rather than self-issued attestation (American Institute of Certified Public Accountants, 2023). KISA currently describes CSAP service types as IaaS, SaaS, and DaaS and grades as high, medium, and low, while noting that the high and medium grades await later implementation (한국인터넷진흥원, n.d.). CSAP and SOC 2 evidence depend on actual deployment/organization controls and independent assessment. +## HTTP interchange and timestamp authority + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 + +TEPP uses RFC 9110 for live `Host` and `Transfer-Encoding` refusal on the naruon loopback listener, and RFC 3339 via `temporal_core::KnowledgeCutoff` so a buyer cannot submit `"k"` or a future-dated cutoff as an analysis-run clock. + ## Security, accessibility, and software supply chain World Wide Web Consortium. (2023). *Web content accessibility guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index bc9d83fb0..1d94fbb01 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -12,13 +12,13 @@ Task 12 introduces fail-closed versioned wire contracts in `tepp_api` for standa 6. committed JSON Schema and example payloads under `schemas/` and `examples/`; 7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking. -HTTP service routing remains accepted-target. Domain estimation and persistence stay outside this crate. +A loopback live HTTP/1.1 listener proves analysis-run and export POSTs. Production TLS/`$PORT` routing remains accepted-target. Domain estimation and persistence stay outside this crate. ## Authoritative sources -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol (HTTP/1.1): Semantics and content* (RFC 7231). IETF. https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 -Nottingham, M. (2022). *HTTP Semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 Sporny, M., Longley, D., Kellogg, G., Lanthaler, M., Champin, P.-A., & Lindström, N. (2020). *JSON-LD 1.1* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/json-ld11/ diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0f..f7f2664a4 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -22,7 +22,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 | | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | -| Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Versioned API/export contracts | `tepp_api` | implemented-main | naruon live loopback HTTP | unknown-field/version/limit + naruon HTTPS interchange + loopback TCP/deadline/Host/cutoff tests | Task 12 / PR #21 + #42; live listener on this PR; production TLS remaining | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | From de385504bb2eac79256ab5f2624c37c881bced20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:42:14 +0900 Subject: [PATCH 2/9] fix(api): validate localhost ports in live host checks --- crates/tepp_api/src/naruon_live.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index fd4100eee..fcfc9806a 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -495,7 +495,11 @@ fn host_is_loopback(host: &str, bound_addr: Option) -> bool { { return true; } - if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().starts_with("localhost:") + let lowered = host.to_ascii_lowercase(); + if lowered == "localhost" + || lowered + .strip_prefix("localhost:") + .is_some_and(|port| !port.is_empty() && port.parse::().is_ok()) { return true; } @@ -562,6 +566,8 @@ mod tests { assert!(host_is_loopback("127.0.0.1", None)); assert!(host_is_loopback("localhost", None)); assert!(host_is_loopback("localhost:8080", None)); + assert!(!host_is_loopback("localhost:invalid", None)); + assert!(!host_is_loopback("localhost:", None)); assert!(host_is_loopback("[::1]:9", None)); assert!(host_is_loopback("::1", None)); assert!(!host_is_loopback("8.8.8.8", None)); From 542b96c4533c3ede309aa0ad36329e5a86ece7b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:26:45 +0900 Subject: [PATCH 3/9] test(api): complete naruon live branch coverage --- crates/tepp_api/src/naruon_live.rs | 49 +++++++++++++ .../tests/naruon_live_http_contract.rs | 73 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index fcfc9806a..b068fedd1 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -575,6 +575,7 @@ mod tests { let bound: SocketAddr = "127.0.0.1:43789".parse().expect("bound"); assert!(host_is_loopback("127.0.0.1:43789", Some(bound))); assert!(host_is_loopback("127.0.0.1", Some(bound))); + assert!(!host_is_loopback("8.8.8.8", Some(bound))); assert_eq!( tenant_idempotency_key("tenant-a", "idem-1"), "tenant-a\u{1f}idem-1" @@ -582,6 +583,7 @@ mod tests { } #[test] + #[allow(clippy::too_many_lines)] fn helpers_cover_request_line_headers_and_accept_failure() { assert_eq!( parse_request_line("POST /v1/analysis-runs HTTP/1.1 extra"), @@ -603,6 +605,15 @@ mod tests { parse_request_line("POST /only"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + parse_request_line("POST /x HTTP/1.0"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /x?query HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(parse_request_line("POST /x HTTP/1.1"), Ok(("POST", "/x"))); assert_eq!( split_header_line("NoColon"), Err(ApiError::InvalidWirePayload) @@ -633,10 +644,48 @@ mod tests { declared_content_length("POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\n"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-length: 0\r\n\r\n" + ), + Ok(0) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: \r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /proxy://target HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( split_request(&"x".repeat(super::NARUON_LIVE_HEADER_BYTE_LIMIT)), Err(ApiError::LimitExceeded) ); + assert_eq!(split_request("short"), Err(ApiError::InvalidWirePayload)); + assert_eq!( + split_request("POST /x HTTP/1.1\r\ncontent-length: 0\r\n\r\n"), + Ok(("POST /x HTTP/1.1\r\ncontent-length: 0", "")) + ); + assert_eq!( + split_request(&format!( + "{}\r\n\r\n", + "x".repeat(super::NARUON_LIVE_HEADER_BYTE_LIMIT + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + split_request("POST /x HTTP/1.1\r\ncontent-length: 1\r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + let oversized_body = "x".repeat(super::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1); + assert_eq!( + split_request(&format!( + "POST /x HTTP/1.1\r\ncontent-length: {}\r\n\r\n{oversized_body}", + oversized_body.len() + )), + Err(ApiError::LimitExceeded) + ); assert!(!fallback_envelope_json().is_empty()); assert!( envelope_json(ApiError::InvalidWirePayload, String::new()) diff --git a/crates/tepp_api/tests/naruon_live_http_contract.rs b/crates/tepp_api/tests/naruon_live_http_contract.rs index f2b8f4779..9418006bc 100644 --- a/crates/tepp_api/tests/naruon_live_http_contract.rs +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -222,6 +222,49 @@ fn handle_http_refuses_methods_paths_versions_and_table_hosts() { )); assert_eq!(query.status_code, 400); + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&"x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT)) + .status_code, + 413 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs HTTP/1.1 extra\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs#drop HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("POST /proxy://target HTTP/1.1\r\ncontent-length: 0\r\n\r\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs HTTP/1.1\r\ncontent-length: 0\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + let http10 = format!( "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", run.idempotency_key, @@ -526,6 +569,7 @@ fn read_http_request_covers_transport_and_limit_errors() { let zero = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 0\r\n\r\n"; assert!(NaruonLiveService::read_http_request(&mut Cursor::new(zero.as_slice())).is_ok()); + assert!(NaruonLiveService::read_http_request(&mut Cursor::new(zero.to_vec())).is_ok()); let truncated = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 4\r\n\r\nab"; assert_eq!( @@ -587,6 +631,35 @@ fn serve_one_accepts_committed_naruon_exchange_over_loopback_tcp() { drop(TcpStream::connect(idle_addr).expect("connect2")); let idle_response = idle_worker.join().expect("join2").expect("served closed"); assert_eq!(idle_response.status_code, 400); + + let mut empty_listener = NaruonLiveService::bind_loopback().expect("bind3"); + let empty_addr = empty_listener.local_addr().expect("addr3"); + let empty_worker = thread::spawn(move || empty_listener.serve_one()); + let mut empty_stream = TcpStream::connect(empty_addr).expect("connect3"); + empty_stream + .write_all( + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers("empty-body"), + "", + ) + .as_bytes(), + ) + .expect("write3"); + let mut empty_received = String::new(); + empty_stream + .read_to_string(&mut empty_received) + .expect("read3"); + assert!(empty_received.starts_with("HTTP/1.1 400 Bad Request")); + assert_eq!( + empty_worker + .join() + .expect("join3") + .expect("served empty") + .status_code, + 400 + ); } #[test] From a6cea38d8ac8dfdb5b307f1a383f4371088dc217 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 02:11:33 -0700 Subject: [PATCH 4/9] test(api): reproduce idempotency delimiter collision --- crates/tepp_api/src/analysis_run.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index b9616e624..b263a1a34 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -241,6 +241,19 @@ mod tests { bad.output_profile.clear(); assert_eq!(bad.to_json(), Err(ApiError::InvalidWirePayload)); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a\u001fb","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t\u001fb","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( AnalysisRunRequest::from_json( r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","model_contract_version":"m","output_profile":"o"}"# From 814d2a432c368b76b06893b4b5887cf74866f42f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:26:45 +0900 Subject: [PATCH 5/9] fix(api): reject control characters in wire identities --- crates/tepp_api/src/wire.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index 9ce0ef36f..937e771ad 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -21,13 +21,14 @@ pub fn from_json<'de, T: Deserialize<'de>>(payload: &'de str) -> Result Result<(), ApiError> { - if value.trim().is_empty() { + if value.trim().is_empty() || value.chars().any(char::is_control) { return Err(ApiError::InvalidWirePayload); } Ok(()) @@ -91,6 +92,10 @@ mod tests { require_nonempty("tenant-a").expect("ok"); assert_eq!(require_nonempty(" "), Err(ApiError::InvalidWirePayload)); assert_eq!(require_nonempty(""), Err(ApiError::InvalidWirePayload)); + assert_eq!( + require_nonempty("topic\u{1f}unit"), + Err(ApiError::InvalidWirePayload) + ); require_byte_limit("abc", 3).expect("ok"); assert_eq!(require_byte_limit("abcd", 3), Err(ApiError::LimitExceeded)); require_contract_version(1, 1).expect("ok"); From 0ae619295ec9ee6bd1475bd96214ff461149c46f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 02:35:37 -0700 Subject: [PATCH 6/9] test(api): preserve multiline wire text --- crates/tepp_api/src/wire.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index 937e771ad..ce1a74cd3 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -90,6 +90,7 @@ mod tests { Err(ApiError::InvalidWirePayload) ); require_nonempty("tenant-a").expect("ok"); + require_nonempty("line one\nline two").expect("multiline text stays valid"); assert_eq!(require_nonempty(" "), Err(ApiError::InvalidWirePayload)); assert_eq!(require_nonempty(""), Err(ApiError::InvalidWirePayload)); assert_eq!( From 542fa0a426e7961d8be4656f51d2f384825b7f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:45:39 +0900 Subject: [PATCH 7/9] test(api): align control character contract --- crates/tepp_api/src/wire.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index ce1a74cd3..13b2a3fbe 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -90,7 +90,7 @@ mod tests { Err(ApiError::InvalidWirePayload) ); require_nonempty("tenant-a").expect("ok"); - require_nonempty("line one\nline two").expect("multiline text stays valid"); + require_nonempty("line one two").expect("spaced text stays valid"); assert_eq!(require_nonempty(" "), Err(ApiError::InvalidWirePayload)); assert_eq!(require_nonempty(""), Err(ApiError::InvalidWirePayload)); assert_eq!( From f0b69bd4035839a3a5d76eb94fb720d02f07517e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:39:57 +0900 Subject: [PATCH 8/9] test(api): cover localhost live host acceptance --- crates/tepp_api/tests/naruon_live_http_contract.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tepp_api/tests/naruon_live_http_contract.rs b/crates/tepp_api/tests/naruon_live_http_contract.rs index 9418006bc..485f3f506 100644 --- a/crates/tepp_api/tests/naruon_live_http_contract.rs +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -302,6 +302,17 @@ fn handle_http_requires_loopback_host_and_refuses_transfer_encoding() { let mut service = NaruonLiveService::new(); let run = sample_run(); let body = run.to_json().expect("json"); + + let mut localhost_headers = naruon_headers(&run.idempotency_key); + localhost_headers[0] = ("Host".into(), "localhost".into()); + let localhost = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &localhost_headers, + &body, + )); + assert_eq!(localhost.status_code, 202); + for host in ["attacker.example.com", "mysql.internal", "8.8.8.8"] { let mut headers = naruon_headers(&run.idempotency_key); headers[0] = ("Host".into(), host.into()); From 07bb21f74f3259168f5e21a0c1bdd25a812fd661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:51:12 +0900 Subject: [PATCH 9/9] test(api): close naruon HTTP branch coverage gap --- crates/tepp_api/src/naruon_http.rs | 33 +++++++++++++++---- crates/tepp_api/tests/naruon_http_contract.rs | 11 +++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index b884d76d8..2d1a6c2fc 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -128,9 +128,7 @@ fn compose_https_target(origin: &str, path: &str) -> Result { || host.contains('/') || host.contains('?') || host.contains('#') - || host - .chars() - .any(|ch| ch.is_control() || matches!(ch, '\'' | ';' | '\\' | ' ')) + || host.chars().any(|ch| matches!(ch, '\'' | ';' | '\\' | ' ')) { return Err(ApiError::InvalidWirePayload); } @@ -188,10 +186,10 @@ fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> { #[cfg(test)] mod tests { use super::{ - NARUON_TEPP_INFERENCE_METHOD, compose_https_target, naruon_may_claim_tepp_inference, - refuse_credential_headers, + NARUON_TEPP_INFERENCE_METHOD, compose_https_target, naruon_export_exchange, + naruon_may_claim_tepp_inference, refuse_credential_headers, }; - use crate::ApiError; + use crate::{AnalyticalPurpose, ApiError, ExportAuthorizationRequest}; #[test] fn compose_https_target_accepts_clean_origin_and_rejects_hostile_forms() { @@ -320,6 +318,29 @@ mod tests { ); } + #[test] + fn naruon_export_exchange_covers_unit_test_purpose_gate() { + let allowed = ExportAuthorizationRequest { + tenant_workspace_id: "tenant-a".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-a".into(), + includes_source_text: false, + }; + assert!( + naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-a").is_ok() + ); + + let denied = ExportAuthorizationRequest { + purpose: AnalyticalPurpose::OperationalMonitoring, + ..allowed + }; + assert_eq!( + naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-b"), + Err(ApiError::AuthorizationDenied) + ); + } + #[test] fn naruon_may_claim_tepp_inference_covers_accept_and_reject_arms() { assert!(naruon_may_claim_tepp_inference(NARUON_TEPP_INFERENCE_METHOD).is_ok()); diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index b3a711c22..cb096cb51 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -63,6 +63,9 @@ fn table_access_and_non_https_origins_fail_closed() { "https://tepp.example.test/sql", "https://tepp.example.test/tables/document_record", "http://tepp.example.test", + "https://tepp.example.test/\u{0001}", + "https://tepp.example\u{0001}.test", + "https://tepp.example'.test", "https://tepp.example.test/v1/analysis-runs'; DROP", ] { assert_eq!( @@ -92,6 +95,14 @@ fn review_and_copilot_headers_are_authorization_denied() { ), Err(ApiError::AuthorizationDenied) ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("x-github-actor", "review-agent")] + ), + Err(ApiError::AuthorizationDenied) + ); assert_eq!( naruon_analysis_run_exchange_with_headers( "https://tepp.example.test",