makepad/libs/asset/store/tests/http/blob_refs_http.rs
Admin 2d23dba736 libs/asset: the store runs on our own SQLite, and the importers learn the whole map contract
The asset store now uses libs/sqlite_query as its ONLY engine — not a feature
flag, not a fallback. That closes the Windows gap (the embedded store starts
there now, and a SHARED->EXCLUSIVE upgrade is handled rather than assumed
free) and takes the C dependency out of the build everywhere else.

Around it:

  - store: a garbage collector, catalogued content that is referenced in place
    instead of copied, the `vjeffect` kind, and host/chat routes that keep up
    with the chat wire below.
  - importer: the unified map contract reaches quake2, quake3, doom and duke —
    world placement, nav, welding, prelit maps and glTF node handling shared
    rather than reimplemented per game. Music import, billboards and stateful
    props move to the data crate so readers stop linking the importer.
  - ai: the serving side of multi-lane chat — per-lane conversations, honest
    progress and acceptance reporting, penalties and a watchdog, context as a
    per-box number that compacts instead of erupting, a realtime session mode,
    and inpaint/flux2 backends. `chat_bench` measures the rate the way the
    client meter computes it.
  - client / chat / chat_ui: a publication can NAME a file instead of carrying
    it; the wire says whether a turn is warm and whether it is thinking, so a
    client stops guessing; transcript and feed widgets render history the way
    the model wrote it. `SessionConfig::catalog_runtime` lets a host size the
    catalog runtime's lanes itself — a browsing UI puts every listing, every
    per-tile resolve and every thumbnail blob through that one runtime and
    wants a wider fast lane than the shared default, while media lanes keep
    it (a few big transfers, not a thousand small ones).
  - widgets: the shared asset widgets — one video view (knobbed seek,
    transport, bracket trim, rail playback) used everywhere, plus thumb,
    preview, scene view, walk-world and the lyric reader.
2026-08-23 01:34:34 +02:00

241 lines
8.9 KiB
Rust

//! Reference blobs over the wire: the real client publishing a real file it
//! never reads, against a real server on a real socket.
//!
//! What this proves that the core-level suite cannot:
//! - the route DOES NOT EXIST unless the embedder turned the policy on —
//! an untouched server answers 404, so no deployment gains a file-read
//! surface by upgrading,
//! - a bundle published by reference produces a manifest that a normal
//! client fetches normally: same digest, same length, same bytes,
//! - the store's directory gained no copy of the payload,
//! - and when the file underneath changes, the fetch REFUSES rather than
//! serving whatever is there now.
use makepad_asset_client::{
ApiEndpoints, AssetClient, ClientConfig, ClientError, PublishBundle, PublishBundleFile,
PublishRights, PublishThumbnail,
};
use makepad_asset_data::{AssetKind, BlobId, FileRole, MediaType, ThumbnailMedia};
use makepad_asset_store::{AssetServer, BlobRefPolicy, ServerConfig};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
fn test_root(name: &str) -> PathBuf {
let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("mp_blobref_http_{}_{}_{}", std::process::id(), n, name))
}
fn start_server(name: &str, refs_on: bool) -> (AssetServer, String, PathBuf) {
let root = test_root(name);
let mut cfg = ServerConfig::new(root.clone());
cfg.control_addr = "127.0.0.1:0".parse().unwrap();
cfg.data_addr = "127.0.0.1:0".parse().unwrap();
cfg.bootstrap_admin = true;
cfg.log = false;
if refs_on {
cfg.blob_refs = BlobRefPolicy::local_host();
}
let server = AssetServer::start(cfg).expect("server start");
let token = std::fs::read_to_string(root.join("admin-token"))
.expect("admin token")
.trim()
.to_string();
(server, token, root)
}
fn connect(server: &AssetServer, token: &str, cache: &str) -> AssetClient {
let mut cfg = ClientConfig::new(test_root(cache));
cfg.token = Some(token.to_string());
let endpoints = ApiEndpoints { control: server.control_addr(), data: server.data_addr() };
AssetClient::connect(cfg, endpoints, Some(server.server_id())).expect("connect")
}
fn thumbnail() -> PublishThumbnail {
PublishThumbnail {
bytes: vec![9u8; 1200],
media: ThumbnailMedia::Jpeg,
width: 512,
height: 512,
views: Vec::new(),
}
}
/// Count regular files under a directory — used to show the CAS did not grow.
fn file_count(root: &Path) -> usize {
let mut n = 0;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
n += 1;
}
}
}
n
}
#[test]
fn reference_admission_is_absent_until_an_embedder_enables_it() {
let (server, token, _root) = start_server("policy_off", false);
let client = connect(&server, &token, "policy_off_cache");
let dir = test_root("policy_off_media");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("clip.mp4");
std::fs::write(&path, b"UNREACHABLE").unwrap();
match client.admit_blob_ref("gen", path.to_str().unwrap()) {
Err(ClientError::NotFound { .. }) => {}
other => panic!("a server with the policy off must not offer the route: {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_bundle_published_by_reference_fetches_like_any_other() {
let (server, token, root) = start_server("publish_ref", true);
let mut client = connect(&server, &token, "publish_ref_cache");
// The user's media directory, entirely outside the store.
let media = test_root("publish_ref_media");
std::fs::create_dir_all(&media).unwrap();
let clip: Vec<u8> = (0..40_000u32).map(|i| (i % 251) as u8).collect();
let path = media.join("opener.mp4");
std::fs::write(&path, &clip).unwrap();
let cas_before = file_count(&root.join("cas"));
let mut bundle = PublishBundle::new(
"gen",
AssetKind::Video,
"Opener",
vec![PublishBundleFile::reference(
FileRole::Video,
MediaType::Mp4,
path.clone(),
None,
)],
thumbnail(),
PublishRights::generated_cc0(),
);
bundle.media_millis = 4000;
let published = client.publish_bundle(&bundle).expect("publish by reference");
// The manifest pins the file's REAL digest and length, measured by the
// server on a file the client never opened.
assert_eq!(published.files.len(), 1);
assert_eq!(published.files[0].blob, BlobId::hash_of(&clip));
assert_eq!(published.files[0].byte_len, clip.len() as u64);
// The CAS gained exactly ONE object — the thumbnail. The 40 KB payload
// was not copied.
let cas_after = file_count(&root.join("cas"));
assert_eq!(
cas_after - cas_before,
1,
"the payload should not have been copied into the CAS"
);
// And it fetches over the wire like anything else, digest-verified by
// the client's own cache on the way in.
let fetched = client.fetch_blob_bytes(&published.files[0].blob, None).expect("fetch");
assert_eq!(fetched, clip);
// Break the file underneath: the store refuses rather than serving
// whatever is at that path now.
let mut tampered = clip.clone();
tampered[0] ^= 0xff;
std::fs::write(&path, &tampered).unwrap();
let mut fresh = connect(&server, &token, "publish_ref_cache2");
match fresh.fetch_blob_bytes(&published.files[0].blob, None) {
Ok(_) => panic!("a drifted reference must never serve"),
Err(_) => {}
}
// Restore, and it works again: the reference was unavailable, not wrong.
std::fs::write(&path, &clip).unwrap();
let mut again = connect(&server, &token, "publish_ref_cache3");
assert_eq!(
again.fetch_blob_bytes(&published.files[0].blob, None).expect("restored"),
clip
);
std::fs::remove_dir_all(&media).ok();
}
#[test]
fn the_prefix_allowlist_is_enforced() {
let root = test_root("allowlist");
let allowed = test_root("allowlist_allowed");
let forbidden = test_root("allowlist_forbidden");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::create_dir_all(&forbidden).unwrap();
std::fs::write(allowed.join("ok.mp4"), b"INSIDE").unwrap();
std::fs::write(forbidden.join("no.mp4"), b"OUTSIDE").unwrap();
let mut cfg = ServerConfig::new(root.clone());
cfg.control_addr = "127.0.0.1:0".parse().unwrap();
cfg.data_addr = "127.0.0.1:0".parse().unwrap();
cfg.bootstrap_admin = true;
cfg.log = false;
cfg.blob_refs = BlobRefPolicy { roots: vec![allowed.clone()], ..BlobRefPolicy::local_host() };
let server = AssetServer::start(cfg).expect("server start");
let token = std::fs::read_to_string(root.join("admin-token")).unwrap().trim().to_string();
let client = connect(&server, &token, "allowlist_cache");
client
.admit_blob_ref("gen", allowed.join("ok.mp4").to_str().unwrap())
.expect("a path under an allowed root is admitted");
assert!(
client
.admit_blob_ref("gen", forbidden.join("no.mp4").to_str().unwrap())
.is_err(),
"a path outside every allowed root must be refused"
);
// …including one that tries to walk out of an allowed root.
let escape = allowed.join("..").join("allowlist_forbidden").join("no.mp4");
assert!(
client.admit_blob_ref("gen", escape.to_str().unwrap()).is_err(),
"a traversal out of an allowed root must be refused"
);
std::fs::remove_dir_all(&allowed).ok();
std::fs::remove_dir_all(&forbidden).ok();
}
#[test]
fn rescan_over_http_reports_what_went_stale() {
let (server, token, _root) = start_server("rescan_http", true);
let client = connect(&server, &token, "rescan_http_cache");
let media = test_root("rescan_http_media");
std::fs::create_dir_all(&media).unwrap();
let good = media.join("good.mp4");
let gone = media.join("gone.mp4");
std::fs::write(&good, b"STILL-HERE").unwrap();
std::fs::write(&gone, b"NOT-FOR-LONG").unwrap();
client.admit_blob_ref("gen", good.to_str().unwrap()).unwrap();
client.admit_blob_ref("gen", gone.to_str().unwrap()).unwrap();
std::fs::remove_file(&gone).unwrap();
let page = client.blob_refs_page(None, 32).expect("rescan");
assert_eq!(page.total, 2);
assert_eq!(page.refs.len(), 2);
let state_of = |name: &str| {
page.refs
.iter()
.find(|r| r.path.ends_with(name))
.map(|r| r.state.clone())
.unwrap_or_default()
};
assert_eq!(state_of("good.mp4"), "present");
assert_eq!(state_of("gone.mp4"), "missing");
std::fs::remove_dir_all(&media).ok();
}