mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-15 09:33:13 +00:00
433 lines
16 KiB
Rust
433 lines
16 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Minimal scripted HTTP responder standing in for a Vault server.
|
|
//!
|
|
//! Wiring tests need to observe how many Vault requests a code path performs
|
|
//! (retries, read-confirm recovery) without a live Vault. The responder serves
|
|
//! one canned response per incoming request in order, closes the connection
|
|
//! after each response, and records the `METHOD /path` sequence for
|
|
//! assertions. It intentionally implements just enough HTTP/1.1 for the
|
|
//! `vaultrs` reqwest client: no keep-alive, no chunked bodies.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
|
|
/// One scripted connection outcome.
|
|
pub(crate) enum ScriptedResponse {
|
|
Http { status: u16, body: String },
|
|
Close,
|
|
}
|
|
|
|
impl ScriptedResponse {
|
|
/// A 200 response carrying `data` inside the standard Vault envelope.
|
|
pub(crate) fn ok(data: serde_json::Value) -> Self {
|
|
Self::Http {
|
|
status: 200,
|
|
body: serde_json::json!({
|
|
"request_id": "scripted",
|
|
"lease_id": "",
|
|
"lease_duration": 0,
|
|
"renewable": false,
|
|
"data": data,
|
|
})
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
/// An error response in Vault's `{"errors": [...]}` format.
|
|
pub(crate) fn error(status: u16, message: &str) -> Self {
|
|
Self::Http {
|
|
status,
|
|
body: serde_json::json!({ "errors": [message] }).to_string(),
|
|
}
|
|
}
|
|
|
|
/// The 404 Vault answers a LIST of an empty path with: something routed the
|
|
/// request and found nothing under it, so the `errors` array comes back
|
|
/// empty. [`ScriptedResponse::error`] cannot stand in — it always fills
|
|
/// `errors`, which is what marks a 404 as an unrouted path instead.
|
|
pub(crate) fn empty_list_404() -> Self {
|
|
Self::Http {
|
|
status: 404,
|
|
body: serde_json::json!({ "errors": [] }).to_string(),
|
|
}
|
|
}
|
|
|
|
/// Close the connection after consuming a request without sending an HTTP response.
|
|
pub(crate) fn close() -> Self {
|
|
Self::Close
|
|
}
|
|
}
|
|
|
|
/// A scripted stand-in Vault listening on a loopback port.
|
|
pub(crate) struct ScriptedVault {
|
|
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
|
|
pub(crate) address: String,
|
|
requests: Arc<Mutex<Vec<(String, String)>>>,
|
|
kv2_state: Option<Arc<Mutex<Kv2State>>>,
|
|
}
|
|
|
|
impl ScriptedVault {
|
|
/// Bind a loopback listener and serve `responses` one per request.
|
|
///
|
|
/// Requests beyond the script get a 599 error so a test that under-scripts
|
|
/// fails loudly instead of hanging.
|
|
pub(crate) async fn serve(responses: Vec<ScriptedResponse>) -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind scripted vault listener");
|
|
let address = format!("http://{}", listener.local_addr().expect("scripted vault local addr"));
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let recorded = Arc::clone(&requests);
|
|
|
|
tokio::spawn(async move {
|
|
let mut responses = responses.into_iter();
|
|
loop {
|
|
let Ok((stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
let Some((request_line, body, mut stream)) = read_request(stream).await else {
|
|
continue;
|
|
};
|
|
recorded
|
|
.lock()
|
|
.expect("scripted vault request log poisoned")
|
|
.push((request_line, body));
|
|
let response = responses
|
|
.next()
|
|
.unwrap_or_else(|| ScriptedResponse::error(599, "scripted vault: script exhausted"));
|
|
if let ScriptedResponse::Http { status, body } = response {
|
|
let payload = format!(
|
|
"HTTP/1.1 {status} Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len(),
|
|
);
|
|
let _ = stream.write_all(payload.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
address,
|
|
requests,
|
|
kv2_state: None,
|
|
}
|
|
}
|
|
|
|
/// Bind a small stateful KV2 responder for concurrency tests.
|
|
///
|
|
/// Unlike Self::serve, this responder evaluates CAS writes against a
|
|
/// shared in-memory record and handles connections concurrently. It models
|
|
/// only the KV2 data and metadata paths used by the rotation protocol; an
|
|
/// unknown request receives a 599 response so a test cannot silently
|
|
/// under-specify the Vault exchange.
|
|
pub(crate) async fn serve_kv2(key_path: &str, key_data: serde_json::Value) -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind scripted vault listener");
|
|
let address = format!("http://{}", listener.local_addr().expect("scripted vault local addr"));
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let state = Arc::new(Mutex::new(Kv2State::new(key_data)));
|
|
let recorded = Arc::clone(&requests);
|
|
let state_for_server = Arc::clone(&state);
|
|
let key_path = key_path.to_string();
|
|
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
let recorded = Arc::clone(&recorded);
|
|
let state = Arc::clone(&state_for_server);
|
|
let key_path = key_path.clone();
|
|
tokio::spawn(async move {
|
|
let Some((request_line, body, stream)) = read_request(stream).await else {
|
|
return;
|
|
};
|
|
recorded
|
|
.lock()
|
|
.expect("scripted vault request log poisoned")
|
|
.push((request_line.clone(), body.clone()));
|
|
let response = state
|
|
.lock()
|
|
.expect("scripted KV2 state poisoned")
|
|
.respond(&key_path, &request_line, &body);
|
|
write_response(stream, response).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
Self {
|
|
address,
|
|
requests,
|
|
kv2_state: Some(state),
|
|
}
|
|
}
|
|
|
|
/// The `METHOD /path` lines of every request served so far, in order.
|
|
pub(crate) fn requests(&self) -> Vec<String> {
|
|
self.requests
|
|
.lock()
|
|
.expect("scripted vault request log poisoned")
|
|
.iter()
|
|
.map(|(line, _)| line.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// The request bodies, in the same order as [`Self::requests`]; empty for
|
|
/// bodyless requests. Lets tests assert what a write actually persisted
|
|
/// (record contents, check-and-set options), not just that a write happened.
|
|
pub(crate) fn request_bodies(&self) -> Vec<String> {
|
|
self.requests
|
|
.lock()
|
|
.expect("scripted vault request log poisoned")
|
|
.iter()
|
|
.map(|(_, body)| body.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Snapshot the in-memory KV2 state used by Self::serve_kv2.
|
|
pub(crate) fn kv2_snapshot(&self) -> Option<Kv2Snapshot> {
|
|
self.kv2_state.as_ref().map(|state| {
|
|
let state = state.lock().expect("scripted KV2 state poisoned");
|
|
Kv2Snapshot {
|
|
current_data: state.current_data.clone(),
|
|
current_secret_version: state.current_secret_version,
|
|
version_records: state.version_records.clone(),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// State captured by the stateful KV2 responder for assertions in wiring tests.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct Kv2Snapshot {
|
|
pub(crate) current_data: serde_json::Value,
|
|
pub(crate) current_secret_version: u64,
|
|
pub(crate) version_records: BTreeMap<u32, serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Kv2State {
|
|
current_data: serde_json::Value,
|
|
current_secret_version: u64,
|
|
history: BTreeMap<u64, serde_json::Value>,
|
|
version_records: BTreeMap<u32, serde_json::Value>,
|
|
}
|
|
|
|
impl Kv2State {
|
|
fn new(current_data: serde_json::Value) -> Self {
|
|
let mut version_records = BTreeMap::new();
|
|
if current_data["baseline_version"].as_u64() == Some(1) {
|
|
version_records.insert(1, current_data.clone());
|
|
}
|
|
Self {
|
|
history: BTreeMap::from([(1, current_data.clone())]),
|
|
current_data,
|
|
current_secret_version: 1,
|
|
version_records,
|
|
}
|
|
}
|
|
|
|
fn respond(&mut self, key_path: &str, request_line: &str, body: &str) -> ScriptedResponse {
|
|
let Some((method, path)) = request_line.split_once(' ') else {
|
|
return ScriptedResponse::error(599, "scripted KV2: malformed request line");
|
|
};
|
|
let data_path = format!("/v1/secret/data/{key_path}");
|
|
let metadata_path = format!("/v1/secret/metadata/{key_path}");
|
|
let version_data_prefix = format!("{data_path}/versions/");
|
|
let version_metadata_path = format!("{metadata_path}/versions");
|
|
|
|
if method == "GET" && path == metadata_path {
|
|
return ScriptedResponse::ok(metadata_data(self.current_secret_version));
|
|
}
|
|
|
|
if method == "LIST" && path == version_metadata_path {
|
|
let keys = self.version_records.keys().map(u32::to_string).collect::<Vec<_>>();
|
|
return ScriptedResponse::ok(serde_json::json!({ "keys": keys }));
|
|
}
|
|
|
|
if let Some(version) = path
|
|
.strip_prefix(&version_data_prefix)
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
{
|
|
return match method {
|
|
"GET" => self
|
|
.version_records
|
|
.get(&version)
|
|
.cloned()
|
|
.map(read_data)
|
|
.unwrap_or_else(|| ScriptedResponse::error(404, "not found")),
|
|
"POST" => self.create_version_record(version, body),
|
|
_ => ScriptedResponse::error(599, "scripted KV2: unsupported version request"),
|
|
};
|
|
}
|
|
|
|
if method == "GET" && path.strip_prefix(&data_path).is_some() {
|
|
let version = path
|
|
.split_once("?version=")
|
|
.and_then(|(_, value)| value.parse::<u64>().ok())
|
|
.unwrap_or(self.current_secret_version);
|
|
return self
|
|
.history
|
|
.get(&version)
|
|
.cloned()
|
|
.map(read_data)
|
|
.unwrap_or_else(|| ScriptedResponse::error(404, "not found"));
|
|
}
|
|
|
|
if method == "POST" && path == data_path {
|
|
return self.write_current_record(body);
|
|
}
|
|
|
|
ScriptedResponse::error(599, "scripted KV2: unexpected request")
|
|
}
|
|
|
|
fn create_version_record(&mut self, version: u32, body: &str) -> ScriptedResponse {
|
|
let body: serde_json::Value = match serde_json::from_str(body) {
|
|
Ok(body) => body,
|
|
Err(_) => return ScriptedResponse::error(400, "invalid JSON"),
|
|
};
|
|
if body["options"]["cas"].as_u64() != Some(0) {
|
|
return ScriptedResponse::error(400, "version record requires create-only CAS");
|
|
}
|
|
if self.version_records.contains_key(&version) {
|
|
return ScriptedResponse::error(400, CAS_CONFLICT_MESSAGE);
|
|
}
|
|
let Some(data) = body.get("data").cloned() else {
|
|
return ScriptedResponse::error(400, "missing data");
|
|
};
|
|
self.version_records.insert(version, data);
|
|
write_ack(1)
|
|
}
|
|
|
|
fn write_current_record(&mut self, body: &str) -> ScriptedResponse {
|
|
let body: serde_json::Value = match serde_json::from_str(body) {
|
|
Ok(body) => body,
|
|
Err(_) => return ScriptedResponse::error(400, "invalid JSON"),
|
|
};
|
|
if body["options"]["cas"].as_u64() != Some(self.current_secret_version) {
|
|
return ScriptedResponse::error(400, CAS_CONFLICT_MESSAGE);
|
|
}
|
|
let Some(data) = body.get("data").cloned() else {
|
|
return ScriptedResponse::error(400, "missing data");
|
|
};
|
|
self.current_secret_version += 1;
|
|
self.current_data = data.clone();
|
|
self.history.insert(self.current_secret_version, data);
|
|
write_ack(self.current_secret_version)
|
|
}
|
|
}
|
|
|
|
const CAS_CONFLICT_MESSAGE: &str = "check-and-set parameter did not match the current version";
|
|
|
|
fn metadata_data(current_version: u64) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"cas_required": false,
|
|
"created_time": "2026-01-01T00:00:00Z",
|
|
"current_version": current_version,
|
|
"delete_version_after": "0s",
|
|
"max_versions": 0,
|
|
"oldest_version": 0,
|
|
"updated_time": "2026-01-01T00:00:00Z",
|
|
"custom_metadata": null,
|
|
"versions": {},
|
|
})
|
|
}
|
|
|
|
fn read_data(data: serde_json::Value) -> ScriptedResponse {
|
|
ScriptedResponse::ok(serde_json::json!({
|
|
"data": data,
|
|
"metadata": {
|
|
"created_time": "2026-01-01T00:00:00Z",
|
|
"deletion_time": "",
|
|
"custom_metadata": null,
|
|
"destroyed": false,
|
|
"version": 1,
|
|
},
|
|
}))
|
|
}
|
|
|
|
fn write_ack(version: u64) -> ScriptedResponse {
|
|
ScriptedResponse::ok(serde_json::json!({
|
|
"created_time": "2026-01-01T00:00:00Z",
|
|
"custom_metadata": null,
|
|
"deletion_time": "",
|
|
"destroyed": false,
|
|
"version": version,
|
|
}))
|
|
}
|
|
|
|
async fn write_response(mut stream: TcpStream, response: ScriptedResponse) {
|
|
if let ScriptedResponse::Http { status, body } = response {
|
|
let payload = format!(
|
|
"HTTP/1.1 {status} Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len(),
|
|
);
|
|
let _ = stream.write_all(payload.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
}
|
|
}
|
|
|
|
/// Read one HTTP/1.1 request (head plus content-length body) and return its
|
|
/// `METHOD /path` line together with the body. Draining the body before
|
|
/// responding keeps the client from seeing a connection reset while it is
|
|
/// still writing.
|
|
async fn read_request(mut stream: TcpStream) -> Option<(String, String, TcpStream)> {
|
|
let mut buffer = Vec::new();
|
|
let mut chunk = [0u8; 4096];
|
|
let head_end = loop {
|
|
if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
break position + 4;
|
|
}
|
|
let read = stream.read(&mut chunk).await.ok()?;
|
|
if read == 0 {
|
|
return None;
|
|
}
|
|
buffer.extend_from_slice(&chunk[..read]);
|
|
};
|
|
|
|
let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned();
|
|
let mut lines = head.lines();
|
|
let request_line = lines.next()?;
|
|
let mut parts = request_line.split_whitespace();
|
|
let method = parts.next()?;
|
|
let path = parts.next()?;
|
|
// rustify appends a lone "?" when an endpoint has no query parameters;
|
|
// strip it so assertions can use the plain path.
|
|
let path = path.strip_suffix('?').unwrap_or(path);
|
|
|
|
let content_length: usize = lines
|
|
.filter_map(|line| {
|
|
let (name, value) = line.split_once(':')?;
|
|
name.eq_ignore_ascii_case("content-length")
|
|
.then(|| value.trim().parse().ok())?
|
|
})
|
|
.next()
|
|
.unwrap_or(0);
|
|
let mut body = buffer[head_end..].to_vec();
|
|
let mut remaining = content_length.saturating_sub(body.len());
|
|
while remaining > 0 {
|
|
let read = stream.read(&mut chunk).await.ok()?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
body.extend_from_slice(&chunk[..read]);
|
|
remaining = remaining.saturating_sub(read);
|
|
}
|
|
body.truncate(content_length);
|
|
|
|
Some((format!("{method} {path}"), String::from_utf8_lossy(&body).into_owned(), stream))
|
|
}
|