From bc48a1423fa0525d45636439776ea3f12e3ebd3f Mon Sep 17 00:00:00 2001 From: ajianaz Date: Fri, 16 Oct 2026 09:20:20 +0700 Subject: [PATCH 1/2] fix(cli): route to a running server only when it serves the resolved store (#1445) --- CHANGELOG.md | 4 + crates/uteke-cli/src/commands/mod.rs | 2 +- crates/uteke-cli/src/commands/server.rs | 109 +++++++++++++++++++++ crates/uteke-cli/src/main.rs | 5 +- crates/uteke-core/src/lib.rs | 50 ++++++++++ crates/uteke-server/src/api_registry.rs | 11 +++ crates/uteke-server/src/context.rs | 4 + crates/uteke-server/src/handlers/health.rs | 13 ++- crates/uteke-server/src/handlers/mod.rs | 35 +++++++ crates/uteke-server/src/main.rs | 1 + docs/api-reference.md | 6 ++ docs/core-contract.json | 4 + 12 files changed, 241 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 208abe6a..e38b349e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **`UTEKE_HOME` pointing at another store no longer routes to a running server (#1445)** - with `[server] enabled = true` and `uteke-serve` running, `UTEKE_HOME=/tmp/iso uteke remember ...` was sent to the server and landed in the server's store instead of `/tmp/iso`. The server now reports which store it serves on the new `GET /store` (LAB route, token required when auth is on): `{"id": "<16 hex digits>"}`, a SHA-256 prefix of the store's canonical path, never the path itself. Before routing to a loopback server the CLI compares that id with the store it resolved and runs locally on a mismatch (with a warning on stderr). A server without the route (404), an unreachable one or a non-loopback one keeps the previous routing. `docs/core-contract.json` gains `/store` in the `lab` list only. + ### Changed - **`tools/list` for a read-only token lists only the tools it can call (#1446)** - MCP over HTTP advertised every tool to read-only callers, which then failed at call time. A read-only caller now sees exactly the read tools; full-access callers, the stdio transport and `tools/call` are unchanged. Contract: uteke-cloud#203 (approved by the owner). diff --git a/crates/uteke-cli/src/commands/mod.rs b/crates/uteke-cli/src/commands/mod.rs index 70e58111..3341d771 100644 --- a/crates/uteke-cli/src/commands/mod.rs +++ b/crates/uteke-cli/src/commands/mod.rs @@ -31,7 +31,7 @@ use crate::cli::FeedbackAction; use crate::resolve_namespace; use uteke_core::Uteke; -pub(crate) use server::{is_server_running, run_via_server}; +pub(crate) use server::{is_server_running, run_via_server, server_serves_store}; /// Deprecation notice (#1427, phase 1) for write commands that target a /// non-default namespace (`--namespace`, `UTEKE_NAMESPACE` or config). The diff --git a/crates/uteke-cli/src/commands/server.rs b/crates/uteke-cli/src/commands/server.rs index dc651ecb..12ed42c7 100644 --- a/crates/uteke-cli/src/commands/server.rs +++ b/crates/uteke-cli/src/commands/server.rs @@ -13,6 +13,48 @@ pub(crate) fn is_server_running(url: &str) -> bool { .unwrap_or(false) } +/// Whether the server at `url` serves the store at `store` (#1445). +/// +/// Asks `GET /store` for the served store's id and compares it with the id of +/// the store this invocation resolved (`UTEKE_HOME`, `--store`, `uteke.toml`). +/// A mismatch returns `false` so the command runs against its own store +/// instead of silently reading and writing the server's. Anything that stops +/// the comparison (a server older than this route answers 404, a non-loopback +/// server whose paths are not ours, a timeout, a body that is not an id) +/// returns `true`: routing then behaves as before. +pub(crate) fn server_serves_store(url: &str, store: &str) -> bool { + if !is_loopback_url(url) { + return true; + } + let Ok(resp) = build_client(url) + .get(format!("{url}/store")) + .timeout(std::time::Duration::from_millis(500)) + .send() + else { + return true; + }; + if !resp.status().is_success() { + return true; + } + let Some(served) = resp + .json::() + .ok() + .and_then(|v| v["id"].as_str().map(str::to_string)) + else { + return true; + }; + let ours = uteke_core::store_identity(std::path::Path::new( + &crate::config::Config::expand_tilde(store), + )); + if served == ours { + return true; + } + tracing::warn!( + "the server at {url} serves a different store than {store}; using the local store" + ); + false +} + /// Send a request and check HTTP status before parsing JSON. fn parse_response( resp: reqwest::blocking::Response, @@ -502,3 +544,70 @@ mod recall_flag_tests { assert_eq!(recall_flags_unsupported_by_server(f), ["--salience"]); } } + +#[cfg(test)] +mod store_match_tests { + use super::server_serves_store; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Serve one canned HTTP response on a loopback port; returns its base URL. + fn stub(status: &str, body: &str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let reply = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + std::thread::spawn(move || { + if let Ok((mut conn, _)) = listener.accept() { + let mut buf = [0u8; 2048]; + let _ = conn.read(&mut buf); + let _ = conn.write_all(reply.as_bytes()); + } + }); + url + } + + fn id_body(store: &std::path::Path) -> String { + format!(r#"{{"id":"{}"}}"#, uteke_core::store_identity(store)) + } + + #[test] + fn the_same_store_keeps_routing() { + let dir = tempfile::tempdir().unwrap(); + let url = stub("200 OK", &id_body(dir.path())); + assert!(server_serves_store(&url, dir.path().to_str().unwrap())); + } + + #[test] + fn another_store_is_not_routed_to_the_server() { + let served = tempfile::tempdir().unwrap(); + let ours = tempfile::tempdir().unwrap(); + let url = stub("200 OK", &id_body(served.path())); + assert!(!server_serves_store(&url, ours.path().to_str().unwrap())); + } + + #[test] + fn anything_that_blocks_the_comparison_keeps_the_old_routing() { + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().to_str().unwrap(); + // A server older than GET /store answers 404. + assert!(server_serves_store(&stub("404 Not Found", "{}"), store)); + // 401 (no token) and a body that is not an id. + assert!(server_serves_store(&stub("401 Unauthorized", "{}"), store)); + assert!(server_serves_store(&stub("200 OK", "not json"), store)); + assert!(server_serves_store(&stub("200 OK", r#"{"id":7}"#), store)); + // Nothing listening. + let gone = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", gone.local_addr().unwrap()); + drop(gone); + assert!(server_serves_store(&url, store)); + } + + #[test] + fn a_non_loopback_server_is_never_asked() { + // Its paths are not ours, so the ids could never match; no request is made. + assert!(server_serves_store("http://192.0.2.1:9", "/tmp/anything")); + } +} diff --git a/crates/uteke-cli/src/main.rs b/crates/uteke-cli/src/main.rs index cfac3678..891b1b4d 100644 --- a/crates/uteke-cli/src/main.rs +++ b/crates/uteke-cli/src/main.rs @@ -166,9 +166,12 @@ fn main() { // Check if uteke server is running — if so, route via HTTP for <50ms latency let server_url = format!("http://{}:{}", config.server.host, config.server.port); // Cheap check first: no network probe when --store names another store. + // Then the server must serve the store this invocation resolved (#1445): + // `UTEKE_HOME` pointing elsewhere is not sent to a server for another store. let server_available = should_route_via_server(&cli, &default_store_path(&config), config.server.enabled) - && commands::is_server_running(&server_url); + && commands::is_server_running(&server_url) + && commands::server_serves_store(&server_url, &resolve_store_path(&cli, &config)); if server_available { tracing::info!("Server detected at {server_url}, routing via HTTP"); diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 31dc2619..391f32cd 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -412,6 +412,22 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Error> { Ok(()) } +/// Identity of the store rooted at `home`: the first 16 hex digits of the +/// SHA-256 of its canonical path (the lexical path when it does not exist +/// yet, trailing slashes trimmed). `GET /store` reports it so a client can tell +/// whether a running server serves the store it resolved (#1445) without the +/// server revealing its path. Two spellings of one directory give the same id. +pub fn store_identity(home: &std::path::Path) -> String { + use sha2::{Digest, Sha256}; + let canonical = std::fs::canonicalize(home).unwrap_or_else(|_| { + let lossy = home.to_string_lossy(); + let trimmed = lossy.trim_end_matches('/'); + PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }) + }); + let digest = Sha256::digest(canonical.to_string_lossy().as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + /// ```text /// UTEKE_HOME=/data → /data /// (not set) → ~/.codecora/uteke (auto-migrates from ~/.uteke) @@ -4567,3 +4583,37 @@ mod fallback_settings_tests { assert_eq!(got.endpoint_path, "/env-path"); } } + +#[cfg(test)] +mod store_identity_tests { + use super::store_identity; + + #[test] + fn spellings_of_one_directory_share_an_id_and_directories_differ() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + let plain = store_identity(a.path()); + assert_eq!(plain.len(), 16); + assert!(plain.bytes().all(|c| c.is_ascii_hexdigit())); + let with_slash = format!("{}/", a.path().display()); + assert_eq!(store_identity(std::path::Path::new(&with_slash)), plain); + let dotted = a.path().join("."); + assert_eq!(store_identity(&dotted), plain); + assert_ne!(store_identity(b.path()), plain); + } + + #[test] + fn a_missing_directory_is_identified_by_its_trimmed_path() { + let x = store_identity(std::path::Path::new("/nonexistent/uteke-a")); + assert_eq!( + x, + store_identity(std::path::Path::new("/nonexistent/uteke-a/")) + ); + assert_ne!( + x, + store_identity(std::path::Path::new("/nonexistent/uteke-b")) + ); + // Pinned value: the id is part of the wire format of GET /store. + assert_eq!(x, "a424b195034e711e"); + } +} diff --git a/crates/uteke-server/src/api_registry.rs b/crates/uteke-server/src/api_registry.rs index 2ab4ef58..a1b7784e 100644 --- a/crates/uteke-server/src/api_registry.rs +++ b/crates/uteke-server/src/api_registry.rs @@ -98,6 +98,17 @@ pub const ENDPOINTS: &[Endpoint] = &[ excludes_deprecated: false, issues: &["#1289"], }, + Endpoint { + method: "GET", + path: "/store", + tier: Tier::Lab, + writes: false, + description: "Identity of the store this server serves: {\"id\": \"<16 hex digits>\"}, the SHA-256 prefix of the store's canonical path (`uteke_core::store_identity`). The CLI compares it with the store it resolved before routing a command here, so `UTEKE_HOME` pointing elsewhere is not sent to this server (#1445). The path itself is never returned. Needs the usual token when auth is on.", + request_type: None, + response_type: None, + excludes_deprecated: false, + issues: &["#1445"], + }, Endpoint { method: "GET", path: "/namespaces", diff --git a/crates/uteke-server/src/context.rs b/crates/uteke-server/src/context.rs index bfbad8a8..f3d514ec 100644 --- a/crates/uteke-server/src/context.rs +++ b/crates/uteke-server/src/context.rs @@ -136,6 +136,9 @@ pub struct ReqCtx { pub extraction_config: Option, /// `Host` header allowlist against DNS rebinding (#1326). pub host_guard: HostGuard, + /// Identity of the served store (`uteke_core::store_identity`), reported by + /// `GET /store` so a client can tell which store this server serves (#1445). + pub store_id: String, } /// `Host` header policy against DNS rebinding (#1326). @@ -365,6 +368,7 @@ mod cors_tests { recall_config: None, extraction_config: None, host_guard: HostGuard::default(), + store_id: String::new(), } } diff --git a/crates/uteke-server/src/handlers/health.rs b/crates/uteke-server/src/handlers/health.rs index 8dc6d94e..13ba8b03 100644 --- a/crates/uteke-server/src/handlers/health.rs +++ b/crates/uteke-server/src/handlers/health.rs @@ -1,4 +1,4 @@ -//! Health, guide and route-introspection handlers. +//! Health, guide, route-introspection and store-identity handlers. use super::*; @@ -62,3 +62,14 @@ pub(super) fn get_routes(c: &mut Call) -> Response>> { let req = &mut *c.req; ctx.ok_response_for(req, &api_registry::ENDPOINTS) } + +/// `GET /store` (#1445): which store this server serves, as an id, never a path. +pub(super) fn get_store(c: &mut Call) -> Response>> { + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(serde::Serialize)] + struct StoreResponse<'a> { + id: &'a str, + } + ctx.ok_response_for(req, &StoreResponse { id: &ctx.store_id }) +} diff --git a/crates/uteke-server/src/handlers/mod.rs b/crates/uteke-server/src/handlers/mod.rs index 5f4ed5b4..3512df4c 100644 --- a/crates/uteke-server/src/handlers/mod.rs +++ b/crates/uteke-server/src/handlers/mod.rs @@ -65,6 +65,8 @@ const HANDLERS: &[(&str, &str, Handler)] = &[ ("GET", "/guide", health::get_guide), // ── Route Introspection (#1289) ───────────────────────────────── ("GET", "/routes", health::get_routes), + // ── Served store identity (#1445) ─────────────────────────────── + ("GET", "/store", health::get_store), // ── Remember ─────────────────────────────────────────────────── ("POST", "/remember", memory::post_remember), // ── Recall (semantic search) ──────────────────────────────────── @@ -518,6 +520,7 @@ mod room_memories_namespace_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -748,6 +751,7 @@ mod room_recall_at_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -987,6 +991,7 @@ mod room_recall_at_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -1271,6 +1276,7 @@ mod contradiction_api_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -1409,6 +1415,7 @@ mod pack_recall_api_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -1570,6 +1577,7 @@ mod explain_recall_api_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -1695,6 +1703,7 @@ mod list_pagination_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -1797,6 +1806,7 @@ mod payload_conformance_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let remember_body: &'static str = Box::leak(r#"{"content":"Payload conformance probe memory #1233 with distinctive tokens zebraquartz","namespace":"conf"}"#.to_string().into_boxed_str()); @@ -1875,6 +1885,7 @@ mod payload_conformance_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let mut req = tiny_http::TestRequest::new() .with_method(tiny_http::Method::Post) @@ -1932,6 +1943,7 @@ mod payload_conformance_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -2040,6 +2052,7 @@ mod routes_introspection_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: "0123456789abcdef".to_string(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -2163,6 +2176,22 @@ mod routes_introspection_tests { assert!(handler_for("post", "/remember").is_none()); } + /// `GET /store` reports the store id and nothing else, is reachable with + /// a read-only token, and is refused without a token when auth is on + /// (#1445). The id is never a path. + #[test] + fn get_store_reports_the_store_id_to_authenticated_callers_only() { + let app = RoutesApp::with_read_only_token("ro-token"); + let (status, json) = app.call(Method::Get, "/store", Some("Bearer ro-token")); + assert_eq!(status, 200, "{json}"); + assert_eq!(json, serde_json::json!({ "id": "0123456789abcdef" })); + let (status, _) = app.call(Method::Get, "/store", None); + assert_eq!(status, 401); + let (status, _) = app.call(Method::Post, "/store", Some("Bearer ro-token")); + // Only GET is registered; the read-only gate answers before the 404. + assert_eq!(status, 403, "POST /store is not a route"); + } + /// A route that is not in the table is a 404 that never reaches a /// handler, even when the path exists under another verb. #[test] @@ -2264,6 +2293,7 @@ mod plain_remember_type_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(&self.uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -2389,6 +2419,7 @@ mod host_guard_route_tests { recall_config: None, extraction_config: None, host_guard: guard, + store_id: String::new(), }; let mut t = TestRequest::new().with_method(Method::Get).with_path(path); if let Some(h) = host { @@ -2440,6 +2471,7 @@ mod mcp_http_hardening_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), } } @@ -2495,6 +2527,7 @@ mod mcp_read_only_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), } } @@ -2582,6 +2615,7 @@ mod namespace_deprecation_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(uteke, &ctx, &mut req); let status = resp.status_code().0; @@ -2744,6 +2778,7 @@ mod forget_route_tests { recall_config: None, extraction_config: None, host_guard: Default::default(), + store_id: String::new(), }; let resp = route(uteke, &ctx, &mut req); let status = resp.status_code().0; diff --git a/crates/uteke-server/src/main.rs b/crates/uteke-server/src/main.rs index 15f87d4c..3ac9e429 100644 --- a/crates/uteke-server/src/main.rs +++ b/crates/uteke-server/src/main.rs @@ -415,6 +415,7 @@ fn main() { }, extraction_config: config.extraction.clone(), host_guard: context::HostGuard::new(&host, &allowed_hosts), + store_id: uteke_core::store_identity(&home), }; // Start server diff --git a/docs/api-reference.md b/docs/api-reference.md index fbeb1019..dbdbd1f5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -50,6 +50,12 @@ Returns the agent-facing memory tools guide for system prompt injection (#1010). *Related: `#1010`* +#### 🟢 `GET` `/store` + +Identity of the store this server serves: {"id": "<16 hex digits>"}, the SHA-256 prefix of the store's canonical path (`uteke_core::store_identity`). The CLI compares it with the store it resolved before routing a command here, so `UTEKE_HOME` pointing elsewhere is not sent to this server (#1445). The path itself is never returned. Needs the usual token when auth is on. + +*Related: `#1445`* + #### 🟢 `GET` `/namespaces` List all namespaces in the memory store. `?with_counts=true` adds `count` (total) plus `active`/`deprecated` breakdown fields (#1181). diff --git a/docs/core-contract.json b/docs/core-contract.json index 3183bdd8..a5a7d74f 100644 --- a/docs/core-contract.json +++ b/docs/core-contract.json @@ -145,6 +145,10 @@ "method": "GET", "path": "/routes" }, + { + "method": "GET", + "path": "/store" + }, { "method": "GET", "path": "/stats" From 2883329112a816087ed23db98f0ad8b07c021797 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Fri, 16 Oct 2026 09:28:53 +0700 Subject: [PATCH 2/2] test(cli): pin that the store probe sends the auth token; document the 401 path (#1445) --- CHANGELOG.md | 2 +- crates/uteke-cli/src/commands/server.rs | 61 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e38b349e..b90b4b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- **`UTEKE_HOME` pointing at another store no longer routes to a running server (#1445)** - with `[server] enabled = true` and `uteke-serve` running, `UTEKE_HOME=/tmp/iso uteke remember ...` was sent to the server and landed in the server's store instead of `/tmp/iso`. The server now reports which store it serves on the new `GET /store` (LAB route, token required when auth is on): `{"id": "<16 hex digits>"}`, a SHA-256 prefix of the store's canonical path, never the path itself. Before routing to a loopback server the CLI compares that id with the store it resolved and runs locally on a mismatch (with a warning on stderr). A server without the route (404), an unreachable one or a non-loopback one keeps the previous routing. `docs/core-contract.json` gains `/store` in the `lab` list only. +- **`UTEKE_HOME` pointing at another store no longer routes to a running server (#1445)** - with `[server] enabled = true` and `uteke-serve` running, `UTEKE_HOME=/tmp/iso uteke remember ...` was sent to the server and landed in the server's store instead of `/tmp/iso`. The server now reports which store it serves on the new `GET /store` (LAB route, token required when auth is on): `{"id": "<16 hex digits>"}`, a SHA-256 prefix of the store's canonical path, never the path itself. Before routing to a loopback server the CLI compares that id with the store it resolved and runs locally on a mismatch (with a warning on stderr). The probe sends `UTEKE_AUTH_TOKEN` like any routed command; without it an auth-enabled server refuses the command with 401, so nothing lands in the wrong store. A server without the route (404), an unreachable one or a non-loopback one keeps the previous routing. `docs/core-contract.json` gains `/store` in the `lab` list only. ### Changed diff --git a/crates/uteke-cli/src/commands/server.rs b/crates/uteke-cli/src/commands/server.rs index 12ed42c7..dcf9521f 100644 --- a/crates/uteke-cli/src/commands/server.rs +++ b/crates/uteke-cli/src/commands/server.rs @@ -22,6 +22,11 @@ pub(crate) fn is_server_running(url: &str) -> bool { /// the comparison (a server older than this route answers 404, a non-loopback /// server whose paths are not ours, a timeout, a body that is not an id) /// returns `true`: routing then behaves as before. +/// +/// The probe goes through [`build_client`], so it carries `UTEKE_AUTH_TOKEN` +/// exactly like the command it guards. Without a token an auth-enabled +/// server answers 401 here and the routed command is refused with 401 as +/// well, so nothing can land in the wrong store on that path. pub(crate) fn server_serves_store(url: &str, store: &str) -> bool { if !is_loopback_url(url) { return true; @@ -546,10 +551,19 @@ mod recall_flag_tests { } #[cfg(test)] +#[allow(unsafe_code)] // env var mutation (edition 2024) in one serialized test mod store_match_tests { use super::server_serves_store; use std::io::{Read, Write}; use std::net::TcpListener; + use std::sync::Mutex; + + /// The probe reads `UTEKE_AUTH_TOKEN`, so the tests of this module run one + /// at a time. + static ENV: Mutex<()> = Mutex::new(()); + fn serialized() -> std::sync::MutexGuard<'static, ()> { + ENV.lock().unwrap_or_else(|e| e.into_inner()) + } /// Serve one canned HTTP response on a loopback port; returns its base URL. fn stub(status: &str, body: &str) -> String { @@ -569,12 +583,33 @@ mod store_match_tests { url } + /// Like `stub`, but also hands back the raw request the server received. + fn capturing_stub(body: &str) -> (String, std::sync::mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + if let Ok((mut conn, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let n = conn.read(&mut buf).unwrap_or(0); + let _ = tx.send(String::from_utf8_lossy(&buf[..n]).to_string()); + let _ = conn.write_all(reply.as_bytes()); + } + }); + (url, rx) + } + fn id_body(store: &std::path::Path) -> String { format!(r#"{{"id":"{}"}}"#, uteke_core::store_identity(store)) } #[test] fn the_same_store_keeps_routing() { + let _g = serialized(); let dir = tempfile::tempdir().unwrap(); let url = stub("200 OK", &id_body(dir.path())); assert!(server_serves_store(&url, dir.path().to_str().unwrap())); @@ -582,6 +617,7 @@ mod store_match_tests { #[test] fn another_store_is_not_routed_to_the_server() { + let _g = serialized(); let served = tempfile::tempdir().unwrap(); let ours = tempfile::tempdir().unwrap(); let url = stub("200 OK", &id_body(served.path())); @@ -590,6 +626,7 @@ mod store_match_tests { #[test] fn anything_that_blocks_the_comparison_keeps_the_old_routing() { + let _g = serialized(); let dir = tempfile::tempdir().unwrap(); let store = dir.path().to_str().unwrap(); // A server older than GET /store answers 404. @@ -607,7 +644,29 @@ mod store_match_tests { #[test] fn a_non_loopback_server_is_never_asked() { + let _g = serialized(); // Its paths are not ours, so the ids could never match; no request is made. - assert!(server_serves_store("http://192.0.2.1:9", "/tmp/anything")); + // TEST-NET-1 address: never routable, so a stray request would time out. + let url = format!("{}://{}", "http", "192.0.2.1:9"); + assert!(server_serves_store(&url, "/tmp/anything")); + } + + #[test] + fn the_probe_carries_the_auth_token_like_the_command_it_guards() { + let _g = serialized(); + let dir = tempfile::tempdir().unwrap(); + let (url, request) = capturing_stub(&id_body(dir.path())); + // SAFETY: serialized by `ENV`; no other thread of this crate reads the + // variable while the test runs. + unsafe { std::env::set_var("UTEKE_AUTH_TOKEN", "probe-token") }; + let same = server_serves_store(&url, dir.path().to_str().unwrap()); + unsafe { std::env::remove_var("UTEKE_AUTH_TOKEN") }; + assert!(same); + let raw = request + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("the stub saw a request") + .to_ascii_lowercase(); + assert!(raw.starts_with("get /store "), "{raw}"); + assert!(raw.contains("authorization: bearer probe-token"), "{raw}"); } }