Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/trusted-server-adapter-fastly/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,26 @@ pub fn to_error_response(report: &Report<TrustedServerError>) -> Response {
Response::from_status(root_error.status_code())
.with_body_text_plain(&format!("{}\n", root_error.user_message()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn config_store_unavailable_renders_503() {
// Locks the end-to-end mapping: a config-store read failure reaches the
// client as 503 via `status_code()` — not bypassed by the adapter.
let report = Report::new(TrustedServerError::ConfigStoreUnavailable {
store_name: "app_config".to_string(),
message: "unavailable or not seeded".to_string(),
});

let response = to_error_response(&report);

assert_eq!(
response.get_status(),
fastly::http::StatusCode::SERVICE_UNAVAILABLE,
"config-store read failure should render as 503 to the client"
);
}
}
43 changes: 43 additions & 0 deletions crates/trusted-server-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub enum TrustedServerError {
#[display("Configuration error: {message}")]
Configuration { message: String },

/// Config store could not be read (unseeded, transient backend, or a listed
/// key missing) — Settings cannot be loaded. Retryable / fix by seeding.
#[display("Config store unavailable: {store_name} - {message}")]
ConfigStoreUnavailable { store_name: String, message: String },

/// Auction orchestration error.
#[display("Auction error: {message}")]
Auction { message: String },
Expand Down Expand Up @@ -123,6 +128,7 @@ impl IntoHttpResponse for TrustedServerError {
Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST,
Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Self::Integration { .. } => StatusCode::BAD_GATEWAY,
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
Expand Down Expand Up @@ -211,6 +217,13 @@ mod tests {
},
StatusCode::SERVICE_UNAVAILABLE,
),
(
TrustedServerError::ConfigStoreUnavailable {
store_name: String::from("app_config"),
message: String::from("store unavailable"),
},
StatusCode::SERVICE_UNAVAILABLE,
),
(
TrustedServerError::Prebid {
message: String::from("adapter error"),
Expand Down Expand Up @@ -286,6 +299,9 @@ mod tests {
TrustedServerError::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
TrustedServerError::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST,
TrustedServerError::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
TrustedServerError::ConfigStoreUnavailable { .. } => {
StatusCode::SERVICE_UNAVAILABLE
}
TrustedServerError::Prebid { .. } => StatusCode::BAD_GATEWAY,
TrustedServerError::Integration { .. } => StatusCode::BAD_GATEWAY,
TrustedServerError::Proxy { .. } => StatusCode::BAD_GATEWAY,
Expand Down Expand Up @@ -400,6 +416,25 @@ mod tests {
assert_eq!(error.user_message(), "Invalid header value");
}

#[test]
fn config_store_unavailable_maps_to_503() {
let error = TrustedServerError::ConfigStoreUnavailable {
store_name: String::from("app_config"),
message: String::from("unavailable or not seeded"),
};
assert_eq!(
error.status_code(),
StatusCode::SERVICE_UNAVAILABLE,
"config-store read failure should map to 503"
);
// Detail stays server-side; the public body is the generic catch-all.
assert_eq!(
error.user_message(),
"An internal error occurred",
"503 client body must not leak internal config-store detail"
);
}

#[test]
fn status_code_maps_each_error_variant_to_expected_http_response() {
// Compile-time guard: adding a TrustedServerError variant without
Expand All @@ -413,6 +448,7 @@ mod tests {
| TrustedServerError::InvalidUtf8 { .. }
| TrustedServerError::InvalidHeaderValue { .. }
| TrustedServerError::KvStore { .. }
| TrustedServerError::ConfigStoreUnavailable { .. }
| TrustedServerError::Prebid { .. }
| TrustedServerError::Integration { .. }
| TrustedServerError::Proxy { .. }
Expand Down Expand Up @@ -499,6 +535,13 @@ mod tests {
},
StatusCode::SERVICE_UNAVAILABLE,
),
(
TrustedServerError::ConfigStoreUnavailable {
store_name: "app_config".to_string(),
message: "config store unavailable".to_string(),
},
StatusCode::SERVICE_UNAVAILABLE,
),
(
TrustedServerError::Auction {
message: "auction failed".to_string(),
Expand Down
148 changes: 135 additions & 13 deletions crates/trusted-server-core/src/settings_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ struct FastlyChunkRef {
///
/// # Errors
///
/// Returns [`TrustedServerError::Configuration`] when the config blob is
/// missing, cannot be read, fails envelope verification, or fails Trusted
/// Server settings validation.
/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when the
/// config blob (or a referenced chunk) cannot be read — store unseeded,
/// transient backend, or a chunk key missing. Returns
/// [`TrustedServerError::Configuration`] (HTTP 500) when the read succeeds but
/// the blob fails envelope/chunk verification or Trusted Server settings
/// validation.
pub fn get_settings_from_services(
services: &RuntimeServices,
) -> Result<Settings, Report<TrustedServerError>> {
Expand Down Expand Up @@ -66,9 +69,10 @@ pub fn default_config_key() -> String {
///
/// # Errors
///
/// Returns [`TrustedServerError::Configuration`] when the config blob is
/// missing, cannot be read, fails envelope verification, or fails Trusted
/// Server settings validation.
/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when the
/// config blob (or a referenced chunk) cannot be read, and
/// [`TrustedServerError::Configuration`] (HTTP 500) when the read succeeds but
/// envelope/chunk verification or settings validation fails.
pub fn get_settings_from_config_store(
config_store: &dyn PlatformConfigStore,
store_name: &StoreName,
Expand All @@ -84,12 +88,14 @@ fn read_config_entry(
store_name: &StoreName,
key: &str,
) -> Result<String, Report<TrustedServerError>> {
let message = format!(
"failed to read Trusted Server app config key `{key}` from config store `{store_name}`"
);
config_store
.get(store_name, key)
.change_context(TrustedServerError::Configuration { message })
.change_context(TrustedServerError::ConfigStoreUnavailable {
store_name: store_name.to_string(),
message: format!(
"unavailable or not seeded (failed to read `{key}`) — run `ts config push`"
),
})
}

fn resolve_fastly_chunk_pointer(
Expand Down Expand Up @@ -160,6 +166,7 @@ fn configuration_error<T>(message: String) -> Result<T, Report<TrustedServerErro
mod tests {
use super::*;
use crate::config_payload::CONFIG_BLOB_KEY;
use crate::error::IntoHttpResponse;
use crate::platform::PlatformError;
use crate::settings::Settings;
use crate::test_support::tests::crate_test_settings_str;
Expand Down Expand Up @@ -269,7 +276,7 @@ mod tests {
}

#[test]
fn fails_when_blob_key_is_missing() {
fn unseeded_blob_is_config_store_unavailable_503() {
let store = MemoryConfigStore {
entries: BTreeMap::new(),
};
Expand All @@ -278,9 +285,124 @@ mod tests {
get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY)
.expect_err("should fail when blob is missing");

// Unseeded store is a read failure → 503, not an opaque 500.
assert_eq!(
err.current_context().status_code(),
http::StatusCode::SERVICE_UNAVAILABLE,
"unseeded config blob should map to 503"
);
// The actionable hint must ride the error chain so it reaches the
// server log; the public 503 body stays generic by design.
assert!(
err.to_string().contains(CONFIG_BLOB_KEY),
"error should mention missing blob key"
format!("{err:?}").contains("ts config push"),
"error chain should carry the actionable `ts config push` hint for logs"
);
}

#[test]
fn missing_chunk_is_config_store_unavailable_503() {
// The blob key resolves to a chunk pointer, but one referenced chunk is
// absent — still a config-store read failure → 503.
let settings =
Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings");
let envelope_json = envelope_json(&settings);
let midpoint = envelope_json.len() / 2;
let first_chunk = envelope_json[..midpoint].to_string();
let second_chunk = envelope_json[midpoint..].to_string();
let first_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.0");
let second_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.1");
let pointer = json!({
"edgezero_kind": FASTLY_CHUNK_POINTER_KIND,
"version": 1,
"envelope_sha256": sha256_hex(envelope_json.as_bytes()),
"envelope_len": envelope_json.len(),
"chunks": [
{ "key": first_key, "sha256": sha256_hex(first_chunk.as_bytes()), "len": first_chunk.len() },
{ "key": second_key, "sha256": sha256_hex(second_chunk.as_bytes()), "len": second_chunk.len() }
]
})
.to_string();
// Seed the pointer + the first chunk only; the second chunk is missing.
let store = MemoryConfigStore {
entries: BTreeMap::from([
(CONFIG_BLOB_KEY.to_string(), pointer),
(first_key, first_chunk),
]),
};

let err =
get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY)
.expect_err("missing chunk must error");

assert_eq!(
err.current_context().status_code(),
http::StatusCode::SERVICE_UNAVAILABLE,
"a referenced chunk missing is a read failure → 503"
);
}

#[test]
fn malformed_blob_stays_500() {
// The blob key reads fine (not a chunk pointer), but its contents are not
// a valid envelope — reconstruct/verify failure → 500, not 503.
let store = MemoryConfigStore {
entries: BTreeMap::from([(
CONFIG_BLOB_KEY.to_string(),
"not a valid blob envelope".to_string(),
)]),
};

let err =
get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY)
.expect_err("malformed blob must error");

assert_eq!(
err.current_context().status_code(),
http::StatusCode::INTERNAL_SERVER_ERROR,
"read-OK-but-invalid blob should stay 500"
);
}

#[test]
fn chunk_verification_failure_stays_500() {
// Pointer + chunks all read successfully, but a chunk's bytes no longer
// match the recorded length/sha — reconstruct/verify failure → 500.
let settings =
Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings");
let envelope_json = envelope_json(&settings);
let midpoint = envelope_json.len() / 2;
let first_chunk = envelope_json[..midpoint].to_string();
let second_chunk = envelope_json[midpoint..].to_string();
let first_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.0");
let second_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.1");
let pointer = json!({
"edgezero_kind": FASTLY_CHUNK_POINTER_KIND,
"version": 1,
"envelope_sha256": sha256_hex(envelope_json.as_bytes()),
"envelope_len": envelope_json.len(),
"chunks": [
{ "key": first_key, "sha256": sha256_hex(first_chunk.as_bytes()), "len": first_chunk.len() },
{ "key": second_key, "sha256": sha256_hex(second_chunk.as_bytes()), "len": second_chunk.len() }
]
})
.to_string();
// Store a corrupted second chunk: reads OK, but fails length/sha checks.
let store = MemoryConfigStore {
entries: BTreeMap::from([
(CONFIG_BLOB_KEY.to_string(), pointer),
(first_key, first_chunk),
(second_key, "corrupted chunk bytes".to_string()),
]),
};

let err =
get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY)
.expect_err("corrupt chunk must error");

assert_eq!(
err.current_context().status_code(),
http::StatusCode::INTERNAL_SERVER_ERROR,
"chunk that reads but fails verification should stay 500"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Design: HTTP-Layer Config-Store Load Hardening (503)

- **Date:** 2026-06-27
- **Author:** Prakash (HTTP-layer / runtime).
- **Status:** implemented on `feature/edgezero-269-http` (stacked on `feature/ts-cli-audit`).
- **Supersedes:** the earlier per-key flatten/hash variant of this design. The
CLI now stores Trusted Server config as a single **blob** (optionally chunked
for Fastly value-size limits — `config_payload::settings_from_config_blob`,
`settings_data::FastlyChunkPointer`), so the load path and this spec are
re-derived against that model.

---

## 1. Problem

The runtime rebuilds `Settings` at boot by reading the `app_config` config
store. Before this change every read failure — including an **unseeded** store —
mapped to `TrustedServerError::Configuration` → **500**, indistinguishable from a
genuine code bug. `trusted-server.toml` is deleted, so an unseeded store is an
expected operational state (fresh install, or cutover before `ts config push`),
not a bug.

## 2. Load sequence (blob model)

`crates/trusted-server-core/src/settings_data.rs`:

```
get_settings_from_services
→ get_settings_from_config_store(store, name, key)
→ read_config_entry(key) // READ — the blob value
→ resolve_fastly_chunk_pointer(value) // if a chunk pointer:
→ read_config_entry(chunk.key) × N // READ — each chunk
→ verify chunk len + sha, envelope len + sha // VERIFY
→ settings_from_config_blob(envelope_json) // VERIFY — parse + validate
```

`read_config_entry` is the **single read seam** (used for both the top-level
blob key and every chunk key). `key` resolves via
`EnvConfig::store_key("config", "app_config")`.

## 3. Behavior matrix (the contract)

The boundary is **"couldn't read the config"** vs **"read it but it's
invalid"** — classified by **call site**, because `PlatformConfigStore::get`
collapses key-absent and transport failure into one `Err` (see §5).

| Situation | Where | Status |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
| Blob key or a referenced chunk cannot be read (store unseeded, transient backend, chunk key missing) | `read_config_entry` | **503** `ConfigStoreUnavailable`, actionable hint `run \`ts config push\`` |
| Chunk len/sha mismatch, envelope len/sha mismatch, unsupported pointer version | `resolve_fastly_chunk_pointer` verify | **500** `Configuration` |
| Blob read OK but not a valid envelope / settings invalid | `settings_from_config_blob` | **500** `Configuration` |
| Seeded + valid | — | `Settings` loads |

503 is correct for the read column: unseeded → seed it; transient → retry.

## 4. Mechanism (one new variant)

`TrustedServerError::ConfigStoreUnavailable { store_name, message }` →
`StatusCode::SERVICE_UNAVAILABLE` (precedent: the existing `KvStore` 503 arm).
Only `read_config_entry`'s `change_context` target changes from `Configuration`
to the new variant; all verify/parse paths are untouched. No `PlatformError`
or `PlatformConfigStore` change.

**Security:** the actionable hint rides the error chain (`Display`) to the
**server log** only. The public 503 body stays the generic
`user_message()` catch-all — no `user_message()` arm is added for this variant —
so internal tooling/paths never leak to clients.

## 5. Out of scope / follow-up

- `PlatformConfigStore::get → Result<Option<String>>` (absence as a value, not an
error) would let the runtime distinguish **unseeded** (`Ok(None)`) from
**transient** (`Err`) precisely instead of classifying by call site. It is the
store-convergence direction (edgezero's own `ConfigStore::get` shape) and
touches every impl + caller across request-signing and DataDome — tracked as a
separate, cross-cutting change, not this PR.
- Non-Fastly adapter (cloudflare/spin/axum) parity rides the EdgeZero adapter
stack.
Loading