Skip to content

Commit 5f68124

Browse files
committed
Add Wasm cache-store content type context
1 parent 952ef6e commit 5f68124

12 files changed

Lines changed: 203 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ behavior when the change improves security or project direction.
1717
`wasm-device-class=mobile|desktop` cache-key variants.
1818
- Add fixed-ID Wasm cache-store TTL overrides and cache tag assignment.
1919
- Add fixed-ID Wasm cache-store response-header metadata for stored objects.
20+
- Add symbolic cache-store response content-type class inspection without raw
21+
response-header exposure.
2022
- Add live listener tests proving separate mobile and desktop cache variants
2123
MISS independently and HIT the original variant on repeat.
2224
- Add live listener tests proving a plugin TTL override expires an otherwise
@@ -25,8 +27,9 @@ behavior when the change improves security or project direction.
2527
on cache HIT and forbidden stored-header IDs fail closed.
2628
- Add a checked-in Wasm cache-policy example plus a config template for the
2729
bounded `1.7.5` cache ABI.
28-
- Add live listener coverage that compiles the checked-in example source and
29-
validates its documented cache-key, TTL, and stored-header behavior.
30+
- Add live listener coverage that compiles the checked-in example sources and
31+
validates their documented image-only cache-key, TTL, and stored-header
32+
behavior.
3033

3134
### Security
3235

@@ -38,8 +41,8 @@ behavior when the change improves security or project direction.
3841
stored-header mutation counts above the hard cap through the plugin fail
3942
mode.
4043
- Keep arbitrary request headers, raw cache-key bytes, request bodies, cached
41-
objects, arbitrary TTLs, arbitrary tag strings, and arbitrary stored response
42-
headers unavailable in this slice.
44+
objects, arbitrary TTLs, arbitrary tag strings, arbitrary response-header
45+
inspection, and arbitrary stored response headers unavailable in this slice.
4346

4447
## 1.7.4 - 2026-07-07
4548

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Fluxheim is licensed under the European Union Public Licence 1.2.
137137
| GeoIP/Geo-Context policy || `1.4.5`; optional `geoip` feature with local MMDB support for MaxMind GeoIP2/GeoLite2 and CIRCL Geo Open datasets, plus vhost/route country and ASN ACLs. |
138138
| Pingora-free runtime || `1.6.34`; normal Fluxheim builds no longer compile Pingora crates. Server/listener/TLS, HTTP/1, HTTP/2, WebSocket, cache, load-balancer, admin, metrics, stream, and background-service paths run through Fluxheim-owned Rust crates. |
139139
| HTTP/3/QUIC || Planned as a Fluxheim-owned `1.9` protocol milestone using the Rust `quinn`/`h3` stack after the `1.8` macOS/Windows production parity line. |
140-
| WASM extensibility | 🧪 | Active `1.7.x` line. `1.7.0` added the optional `wasm` feature, strict plugin-file loading, bounded Wasmtime execution, and real-Wasm smoke coverage. `1.7.1` adds config-level plugin registry validation, deterministic attachment ordering, admission limits, metrics, and live native HTTP/1 access-decision hooks. `1.7.2` adds bounded native HTTP/1 request/response header hooks. `1.7.3` starts bounded native HTTP/1 route-decision hooks with configured canary and mirror branch selection, including selected native load-balanced and persistent routes. `1.7.4` starts VCL-like cache-policy hooks with bounded cache-lookup and cache-store decisions for continue/pass/bypass/skip-store/deny around cache lookup and storage. `1.7.5` adds bounded symbolic cache-key component hooks for low-cardinality cache variants plus fixed-ID cache-store TTL/tag/header metadata and a checked-in cache-policy example. Direct backend choice, plugin-provided persistence keys, dynamic mirror/shadow target choice, and richer store policy hooks remain staged for later `1.7.x`. |
140+
| WASM extensibility | 🧪 | Active `1.7.x` line. `1.7.0` added the optional `wasm` feature, strict plugin-file loading, bounded Wasmtime execution, and real-Wasm smoke coverage. `1.7.1` adds config-level plugin registry validation, deterministic attachment ordering, admission limits, metrics, and live native HTTP/1 access-decision hooks. `1.7.2` adds bounded native HTTP/1 request/response header hooks. `1.7.3` starts bounded native HTTP/1 route-decision hooks with configured canary and mirror branch selection, including selected native load-balanced and persistent routes. `1.7.4` starts VCL-like cache-policy hooks with bounded cache-lookup and cache-store decisions for continue/pass/bypass/skip-store/deny around cache lookup and storage. `1.7.5` adds bounded symbolic cache-key component hooks for low-cardinality cache variants plus fixed-ID cache-store TTL/tag/header metadata, symbolic content-type inspection, and a checked-in cache-policy example. Direct backend choice, plugin-provided persistence keys, dynamic mirror/shadow target choice, and richer store policy hooks remain staged for later `1.7.x`. |
141141

142142
See [Production Readiness](docs/production-readiness.md) for the precise
143143
stable-core promise and deployment checks. See

crates/fluxheim-server/src/native_http1_route_proxy_tests/upstream_cache.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,48 @@ pub(super) async fn upstream_cacheable_sequence(
178178
addr
179179
}
180180

181+
#[cfg(feature = "wasm")]
182+
pub(super) async fn upstream_cacheable_content_type_sequence(
183+
responses: &'static [(&'static str, &'static str, &'static str)],
184+
) -> std::net::SocketAddr {
185+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
186+
let addr = listener.local_addr().unwrap();
187+
tokio::spawn(async move {
188+
for (expected_path, content_type, body) in responses {
189+
let (mut stream, _) = listener.accept().await.unwrap();
190+
let mut request = Vec::new();
191+
let mut chunk = [0u8; 1024];
192+
loop {
193+
let read = stream.read(&mut chunk).await.unwrap();
194+
if read == 0 {
195+
break;
196+
}
197+
request.extend_from_slice(&chunk[..read]);
198+
if request.windows(4).any(|window| window == b"\r\n\r\n") {
199+
break;
200+
}
201+
}
202+
let request = String::from_utf8(request).unwrap();
203+
assert!(
204+
request.starts_with(&format!("GET {expected_path} HTTP/1.1\r\n")),
205+
"unexpected upstream request: {request:?}"
206+
);
207+
stream
208+
.write_all(
209+
format!(
210+
"HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncache-control: max-age=60\r\ncontent-length: {}\r\n\r\n{}",
211+
body.len(),
212+
body
213+
)
214+
.as_bytes(),
215+
)
216+
.await
217+
.unwrap();
218+
}
219+
});
220+
addr
221+
}
222+
181223
pub(super) async fn upstream_vary_sequence(
182224
responses: &'static [(&'static str, &'static str, &'static str, &'static str)],
183225
) -> std::net::SocketAddr {

crates/fluxheim-server/src/native_http1_route_proxy_tests/wasm.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,75 @@ async fn native_wasm_cache_policy_example_matches_documented_behavior() {
11151115
);
11161116
}
11171117

1118+
#[tokio::test]
1119+
async fn native_wasm_cache_policy_example_leaves_non_image_store_metadata_unchanged() {
1120+
let fixture = WasmRouteFixture::new(&[
1121+
("cache_lookup", WasmPluginBody::CacheLookupPolicyExample),
1122+
("cache_store", WasmPluginBody::CacheStorePolicyExample),
1123+
]);
1124+
let upstream = super::upstream_cacheable_content_type_sequence(&[(
1125+
"/static/non-image.png",
1126+
"text/plain",
1127+
"text-body",
1128+
)])
1129+
.await;
1130+
let mut config = fixture.config_with_attachments(
1131+
upstream,
1132+
vec![
1133+
wasm_attachment_phase(
1134+
"cache_lookup",
1135+
"route",
1136+
100,
1137+
fluxheim_config::WasmPluginPhase::CacheLookup,
1138+
),
1139+
wasm_attachment_phase(
1140+
"cache_store",
1141+
"route",
1142+
100,
1143+
fluxheim_config::WasmPluginPhase::CacheStore,
1144+
),
1145+
],
1146+
);
1147+
config.vhosts[0].routes[0].path_exact = None;
1148+
config.vhosts[0].routes[0].path_prefix = Some("/".to_owned());
1149+
config.vhosts[0].routes[0].redirect = None;
1150+
config.vhosts[0].routes[0].proxy = Some(fluxheim_config::ProxyConfig {
1151+
upstreams: vec![upstream.to_string()],
1152+
..Default::default()
1153+
});
1154+
let mut cache = native_proxy_memory_cache_config();
1155+
cache.content_types.push("text/plain".to_owned());
1156+
config.vhosts[0].routes[0].cache = Some(cache);
1157+
let router =
1158+
NativeHttp1HostRouter::from_config(&config, DownstreamHttp1Policy::default(), 0).unwrap();
1159+
let proxy = router_listener(router).await;
1160+
1161+
let first = downstream_get(proxy, "/static/non-image.png").await;
1162+
let hit = downstream_get(proxy, "/static/non-image.png").await;
1163+
1164+
assert!(
1165+
first.starts_with("HTTP/1.1 200 OK\r\n"),
1166+
"unexpected text first response: {first:?}"
1167+
);
1168+
assert!(first.ends_with("text-body"));
1169+
assert_eq!(
1170+
response_header(&first, "x-cache-status").as_deref(),
1171+
Some("MISS")
1172+
);
1173+
assert_eq!(response_header(&first, "x-fluxheim-cache-policy"), None);
1174+
1175+
assert!(
1176+
hit.starts_with("HTTP/1.1 200 OK\r\n"),
1177+
"unexpected text hit response: {hit:?}"
1178+
);
1179+
assert!(hit.ends_with("text-body"));
1180+
assert_eq!(
1181+
response_header(&hit, "x-cache-status").as_deref(),
1182+
Some("HIT")
1183+
);
1184+
assert_eq!(response_header(&hit, "x-fluxheim-cache-policy"), None);
1185+
}
1186+
11181187
#[tokio::test]
11191188
async fn native_wasm_cache_store_deny_wins_over_earlier_skip() {
11201189
let fixture = WasmRouteFixture::new(&[

crates/fluxheim-server/src/native_http1_route_wasm.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const HOST_CONTEXT_CANARY_HEADER: i32 = 2;
4242
const HOST_CONTEXT_MIRROR_HEADER: i32 = 3;
4343
const HOST_CONTEXT_RESPONSE_STATUS: i32 = 4;
4444
const HOST_CONTEXT_DEVICE_CLASS_HEADER: i32 = 5;
45+
const HOST_CONTEXT_RESPONSE_CONTENT_TYPE_CLASS: i32 = 6;
4546
const HEADER_X_POLICY_TIER: i32 = 1;
4647
const HEADER_X_FLUXHEIM_POLICY_BRANCH: i32 = 2;
4748
const HEADER_X_POWERED_BY: i32 = 3;
@@ -59,6 +60,10 @@ const VALUE_STATIC: i32 = 3;
5960
const VALUE_GOLD: i32 = 4;
6061
const VALUE_MOBILE: i32 = 5;
6162
const VALUE_DESKTOP: i32 = 6;
63+
const VALUE_CONTENT_TYPE_IMAGE: i32 = 7;
64+
const VALUE_CONTENT_TYPE_HTML: i32 = 8;
65+
const VALUE_CONTENT_TYPE_JSON: i32 = 9;
66+
const VALUE_CONTENT_TYPE_TEXT: i32 = 10;
6267
const PATH_CLASS_OTHER: i32 = 0;
6368
const PATH_CLASS_API: i32 = 1;
6469
const PATH_CLASS_STATIC: i32 = 2;
@@ -1012,6 +1017,7 @@ pub(crate) struct NativeWasmCacheLookupContext {
10121017
pub(crate) struct NativeWasmCacheStoreContext {
10131018
path_class: i32,
10141019
response_status: i32,
1020+
response_content_type_class: i32,
10151021
}
10161022

10171023
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1081,6 +1087,7 @@ impl NativeWasmCacheStoreContext {
10811087
Self {
10821088
path_class,
10831089
response_status: i32::from(response.status()),
1090+
response_content_type_class: wasm_response_content_type_class(response),
10841091
}
10851092
}
10861093
}
@@ -1252,6 +1259,7 @@ fn wasm_cache_store_host_functions(
12521259
move |kind, _unused| match kind {
12531260
HOST_CONTEXT_PATH_CLASS => Ok(context.path_class),
12541261
HOST_CONTEXT_RESPONSE_STATUS => Ok(context.response_status),
1262+
HOST_CONTEXT_RESPONSE_CONTENT_TYPE_CLASS => Ok(context.response_content_type_class),
12551263
_ => Err("unknown wasm context field".to_owned()),
12561264
},
12571265
);
@@ -1442,6 +1450,33 @@ fn wasm_device_class_header(request: &NativeHttp1Request) -> i32 {
14421450
.unwrap_or(0)
14431451
}
14441452

1453+
fn wasm_response_content_type_class(response: &NativeHttp1Response) -> i32 {
1454+
let Some((_, value)) = response
1455+
.headers()
1456+
.iter()
1457+
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
1458+
else {
1459+
return 0;
1460+
};
1461+
let media_type = value
1462+
.split(';')
1463+
.next()
1464+
.unwrap_or_default()
1465+
.trim()
1466+
.to_ascii_lowercase();
1467+
if media_type.starts_with("image/") {
1468+
VALUE_CONTENT_TYPE_IMAGE
1469+
} else if media_type == "text/html" || media_type == "application/xhtml+xml" {
1470+
VALUE_CONTENT_TYPE_HTML
1471+
} else if media_type == "application/json" || media_type.ends_with("+json") {
1472+
VALUE_CONTENT_TYPE_JSON
1473+
} else if media_type.starts_with("text/") {
1474+
VALUE_CONTENT_TYPE_TEXT
1475+
} else {
1476+
0
1477+
}
1478+
}
1479+
14451480
fn locked_wasm_cache_key_components(
14461481
state: &Arc<Mutex<Vec<NativeProxyCacheKeyComponent>>>,
14471482
) -> Vec<NativeProxyCacheKeyComponent> {

docs/versioning-plan.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3127,9 +3127,10 @@ Stable scope:
31273127
without storing while normal requests still produce MISS then HIT, and that a
31283128
plugin can skip or deny storage before memory/disk writes.
31293129
- `v1.7.5`: add the next bounded cache-policy ABI slice for cache-key
3130-
components, TTL override, tag assignment, store-admission header inspection,
3131-
and safe response-header mutation, with live tests for TTL bounds, tag
3132-
assignment, and low-cardinality key validation.
3130+
components, TTL override, tag assignment, symbolic store-admission
3131+
content-type inspection, and safe fixed response-header mutation, with live
3132+
tests for TTL bounds, tag assignment, image-only store metadata, and
3133+
low-cardinality key validation.
31333134
- `v1.7.6`: harden the mature plugin runtime after the request, response,
31343135
routing, and cache hook families exist. Finish atomic compiled-module reload
31353136
generation handling, broaden admin and metrics visibility across all hook
@@ -4776,9 +4777,10 @@ circular dependencies.
47764777
lookup/pass/bypass/deny and store continue/skip/deny decisions, with live
47774778
cache HIT/MISS and skip-store tests.
47784779
- `v1.7.5`: VCL-like cache policy mutation hooks for bounded symbolic
4779-
cache-key components and fixed-ID TTL/tag store metadata, with live
4780-
low-cardinality key and TTL expiry tests. Richer store-admission mutation
4781-
remains staged for a later cache-policy slice.
4780+
cache-key components, fixed-ID TTL/tag store metadata, symbolic content-type
4781+
inspection, and fixed stored response-header metadata, with live
4782+
low-cardinality key, image-only metadata, and TTL expiry tests. Richer
4783+
store-admission mutation remains staged for a later cache-policy slice.
47824784
- `v1.7.6`: mature-runtime hardening across all hook families: compiled-module
47834785
cache isolation, cross-family chain regression tests, reload hash-change
47844786
tests, metrics/admin completeness, and secret-safe labels. The initial

docs/wasm-extensibility.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ Plugins attached to `cache-store` export `fluxheim_cache_store() -> i32`:
188188
- `2`: deny with `403`.
189189

190190
The cache store host-call surface exposes only the path class, response status,
191-
fixed TTL IDs, fixed tag IDs, and one fixed stored response-header family.
191+
symbolic response content-type class, fixed TTL IDs, fixed tag IDs, and one
192+
fixed stored response-header family.
192193
Plugins can call `set_cache_ttl(1, 0)` for a short bounded TTL,
193194
`set_cache_ttl(2, 0)` for a medium bounded TTL, `add_cache_tag(1, 0)` for
194195
`wasm-policy`, `add_cache_tag(2, 0)` for `wasm-gold`,
@@ -201,6 +202,11 @@ request bodies, arbitrary cache-key bytes, arbitrary TTLs, arbitrary tag
201202
strings, arbitrary stored response headers, cached objects, and response-store
202203
body mutation are not exposed in `1.7.5`.
203204

205+
Cache-store response-header inspection is also symbolic. `context(6, 0)`
206+
returns `0` for unset/other, `7` for image media types, `8` for HTML, `9` for
207+
JSON, and `10` for text media types. Plugins cannot read raw response header
208+
names or values through this cache-store surface.
209+
204210
Cache-store chains use the same restrictive aggregation model as other hook
205211
families: every hook runs unless a hook returns `deny`; an earlier `skip` does
206212
not mask a later `deny`.
@@ -210,8 +216,8 @@ not mask a later `deny`.
210216
subset as concrete Wasm Text modules, and `examples/wasm/cache-policy.toml`
211217
shows the matching plugin/attachment config shape. The example is test-backed
212218
by a live native HTTP/1 listener test that compiles the checked-in sources and
213-
verifies mobile/desktop cache variants, short TTL expiry, and fixed stored
214-
response headers.
219+
verifies mobile/desktop cache variants, image-only store metadata, short TTL
220+
expiry, and fixed stored response headers.
215221

216222
Allowed hooks:
217223

docs/wasm-policy-example-parity.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ Required live test:
147147
Target line: starts in `v1.7.4` with cache lookup/pass/bypass/deny decisions
148148
and cache-store skip/deny decisions. `v1.7.5` adds bounded symbolic cache-key
149149
components with live low-cardinality variant tests plus fixed-ID TTL/tag/header
150-
store metadata with live TTL expiry and stored-header HIT coverage. The
150+
store metadata and symbolic response content-type inspection with live TTL
151+
expiry and stored-header HIT coverage. The
151152
checked-in `examples/wasm/cache-lookup-policy.wat` and
152153
`examples/wasm/cache-store-policy.wat` fixtures plus the
153154
`examples/wasm/cache-policy.toml` config template document this subset, and the

examples/wasm/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ production.
1212
- apply a bounded short TTL;
1313
- add the fixed `wasm-policy` cache tag;
1414
- add the fixed stored response header `x-fluxheim-cache-policy: wasm`.
15+
- apply those store mutations only when Fluxheim reports the response
16+
content-type as the symbolic image class.
1517

1618
The example intentionally uses separate modules for lookup and store phases
1719
because Fluxheim links only the host calls valid for the current phase. It also
Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
(module
2+
(import "fluxheim_policy_v1" "context" (func $context (param i32 i32) (result i32)))
23
(import "fluxheim_policy_v1" "set_cache_ttl" (func $set_cache_ttl (param i32 i32) (result i32)))
34
(import "fluxheim_policy_v1" "add_cache_tag" (func $add_cache_tag (param i32 i32) (result i32)))
45
(import "fluxheim_policy_v1" "set_cache_store_header" (func $set_cache_store_header (param i32 i32) (result i32)))
56

67
(func (export "fluxheim_cache_store") (result i32)
7-
i32.const 1
8+
i32.const 6
89
i32.const 0
9-
call $set_cache_ttl
10-
drop
10+
call $context
11+
i32.const 7
12+
i32.eq
13+
if
14+
i32.const 1
15+
i32.const 0
16+
call $set_cache_ttl
17+
drop
1118

12-
i32.const 1
13-
i32.const 0
14-
call $add_cache_tag
15-
drop
19+
i32.const 1
20+
i32.const 0
21+
call $add_cache_tag
22+
drop
1623

17-
i32.const 1
18-
i32.const 1
19-
call $set_cache_store_header
20-
drop
24+
i32.const 1
25+
i32.const 1
26+
call $set_cache_store_header
27+
drop
28+
end
2129

2230
i32.const 0))

0 commit comments

Comments
 (0)