makepad/libs/asset/client/tests/integration.rs
2026-09-02 15:30:29 +02:00

2502 lines
98 KiB
Rust

//! End-to-end integration over real sockets: discovery → authenticated
//! selection → paginated browse/search → digest-verified manifest and blob
//! fetch with Range resume → pinned, budgeted cache → resolver → runtime
//! states. The fixture serves REAL canonical manifests; every digest the
//! client checks is a genuine SHA-256 of genuine bytes.
// Native socket integration test: std deadlines cannot enter the wasm library.
#![allow(clippy::disallowed_types, clippy::disallowed_methods)]
mod common;
use common::{
payload, response_head, test_root, write_bytes_resp, write_error, write_json_resp, write_raw,
FixtureOptions, FixtureServer, FixtureStore, ParsedRequest, RawServer,
};
use makepad_asset_client::json::{obj, s, Value};
use makepad_asset_client::{
AssetClient, CacheBudgets, CatalogQuery, ClientConfig, ClientError, ClientEvent, ClientOutput,
ClientRequest, ClientRuntime, ClosureBudget, DiscoveryListener, HttpLimits, ResourceSlot,
RuntimeConfig, SubmitOptions,
SourceCollectionRegistered, TierPreference,
};
use makepad_asset_data::*;
use std::collections::HashMap;
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
fn fast_limits() -> HttpLimits {
HttpLimits {
connect_timeout_ms: 2_000,
read_timeout_ms: 1_000,
write_timeout_ms: 2_000,
head_deadline_ms: 2_000,
body_deadline_ms: 5_000,
}
}
fn config(name: &str) -> ClientConfig {
let mut cfg = ClientConfig::new(test_root(name));
cfg.http = fast_limits();
cfg.blob_body_deadline_ms = 5_000;
cfg
}
fn pin_marker(cache_root: &Path, blob: &BlobId) -> PathBuf {
cache_root
.join("pins")
.join(makepad_asset_client::util::to_hex(blob.as_bytes()))
}
fn wait_runtime(
runtime: &mut ClientRuntime,
request: makepad_asset_client::RequestId,
) -> Result<ClientOutput, ClientError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
assert!(
std::time::Instant::now() < deadline,
"runtime request {request} never finished"
);
for event in runtime.poll() {
match event {
ClientEvent::Done { id, output } if id == request => return Ok(output),
ClientEvent::Failed { id, error } if id == request => return Err(error),
_ => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// A store with aliased props, a dependency chain, and a game revision.
fn seeded_store() -> (FixtureStore, Vec<AssetRevisionRef>) {
let mut store = FixtureStore::default();
let mut refs = Vec::new();
// Ten rockets for pagination.
for i in 0..10u8 {
let r = store.add_prop(
10 + i,
"stock",
(i == 0).then_some("stock/rocket-launcher"),
&format!("Rocket {i}"),
payload(100 + i as u64, 2_000 + i as usize * 100),
vec![],
);
refs.push(r);
}
// A crate in another namespace.
store.add_prop(40, "props", Some("props/crate"), "Wooden Crate", payload(200, 1_500), vec![]);
(store, refs)
}
/// The VJ filter contract: `exclude_tag` travels on the wire and the SERVER
/// drops the rows — the client never post-filters a page, so `total`, the
/// page contents and the cursor walk all agree with the exclusion.
#[test]
fn catalog_search_exclude_tag_is_filtered_server_side() {
let (mut store, refs) = seeded_store();
// Rockets 1, 3, 5, 7, 9 are intermediates; 3 also carries `keep`.
for (i, r) in refs.iter().enumerate() {
if i % 2 == 1 {
store.tag_asset(r, &["keep", "intermediate"]);
} else {
store.tag_asset(r, &["keep"]);
}
}
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client =
AssetClient::connect(config("exclude_tag"), fixture.endpoints(), None).unwrap();
let mut q = CatalogQuery::text("rocket", 10);
q.tag = Some("keep".into());
assert_eq!(client.catalog_search(&q, None).unwrap().total, 10);
q.exclude_tag = Some("intermediate".into());
let page = client.catalog_search(&q, None).unwrap();
assert_eq!(page.total, 5, "the server dropped the intermediates");
assert_eq!(page.hits.len(), 5);
assert!(page.next.is_none());
let kept: Vec<AssetId> =
refs.iter().step_by(2).map(|r| r.asset_id).collect();
let mut got: Vec<AssetId> = page.hits.iter().map(|h| h.asset_id).collect();
got.sort();
let mut want = kept.clone();
want.sort();
assert_eq!(got, want);
// Paging over the excluded set: excluded rows interleave the kept ones,
// and the cursor walk still yields each kept row exactly once.
q.page_size = 2;
let mut seen = Vec::new();
let mut cursor = None;
for _ in 0..10 {
let page = client.catalog_search(&q, cursor.as_ref()).unwrap();
assert_eq!(page.total, 5);
seen.extend(page.hits.iter().map(|h| h.asset_id));
match page.next {
Some(next) => cursor = Some(next),
None => break,
}
}
seen.sort();
assert_eq!(seen, want);
}
#[test]
fn discovery_select_connect_browse_paginate() {
let token = format!("mpat_{}", "ab".repeat(32));
let (store, _refs) = seeded_store();
let options = FixtureOptions {
server_id: [0x42; 16],
auth_token: Some(token.clone()),
..FixtureOptions::default()
};
let fixture = FixtureServer::start(store, options);
// ---- discovery on a loopback ephemeral port ----
let listener = DiscoveryListener::start(0, 60_000, now_ms).unwrap();
fixture.send_beacon(listener.port(), true);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let candidate = loop {
if let Some(c) = listener.pick(makepad_asset_client::content_client_caps(), now_ms())
{
break c;
}
assert!(std::time::Instant::now() < deadline, "no beacon received");
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert_eq!(candidate.server_id, [0x42; 16]);
assert!(candidate.auth_required);
// ---- authenticated selection ----
let mut cfg = config("discovery_connect");
cfg.token = Some(token);
let client = AssetClient::connect_discovered(cfg, &candidate).unwrap();
assert_eq!(client.server_id(), [0x42; 16]);
// ---- catalog search pagination: exact continuation, no dup/skip ----
let query = CatalogQuery::text("rocket", 3);
let mut seen = Vec::new();
let mut cursor = None;
let mut pages = 0;
loop {
let page = client.catalog_search(&query, cursor.as_ref()).unwrap();
assert_eq!(page.total, 10);
assert!(page.hits.len() <= 3);
seen.extend(page.hits.iter().map(|h| h.asset_id));
pages += 1;
match page.next {
Some(next) => cursor = Some(next),
None => break,
}
assert!(pages < 10, "pagination never terminated");
}
assert_eq!(pages, 4, "10 hits at page size 3");
assert_eq!(seen.len(), 10);
let mut dedup = seen.clone();
dedup.sort();
dedup.dedup();
assert_eq!(dedup.len(), 10, "pagination duplicated a row");
// ---- keyset listing with namespace filter ----
let mut listed = Vec::new();
let mut cursor = None;
loop {
let page = client.assets_page(Some("stock"), cursor.as_ref(), 4).unwrap();
listed.extend(page.assets.iter().map(|a| a.asset_id));
for a in &page.assets {
assert_eq!(a.namespace, "stock");
}
match page.next {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(listed.len(), 10);
let mut sorted = listed.clone();
sorted.sort_by_key(|id| id.to_string());
assert_eq!(listed, sorted, "keyset listing must be ordered");
}
#[test]
fn alias_to_verified_file_end_to_end() {
let (store, _refs) = seeded_store();
let glb_bytes = {
// Original bytes of the aliased rocket's render blob.
let a = store
.assets
.iter()
.find(|a| a.alias.as_deref() == Some("stock/rocket-launcher"))
.unwrap();
let file = &a.manifest.files[0];
store.blobs[file.blob.as_bytes()].clone()
};
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("alias_e2e"), fixture.endpoints(), None).unwrap();
// alias → head revision → detail agrees
let alias = AssetAlias::from_str("stock/rocket-launcher").unwrap();
let head = client.resolve_alias(&alias).unwrap();
let detail = client.asset_detail(&head.asset_id).unwrap();
assert_eq!(detail.latest_published().unwrap().revision, head.head_revision);
// manifest: typed, digest-verified, byte-cached
let manifest = client.fetch_asset_manifest(&head.head_revision).unwrap();
assert_eq!(manifest.asset_id, head.asset_id);
// resolver: role-selected, digest-verified local file
let resolved = client
.resolve_file(
&manifest,
FileRole::RenderGlb,
TierPreference::PreferWithAnyFallback(DeviceTier::High),
7,
None,
)
.unwrap();
assert_eq!(std::fs::read(resolved.content.as_path().unwrap()).unwrap(), glb_bytes);
assert_eq!(resolved.byte_len, glb_bytes.len() as u64);
// thumbnail is typed and materialized
let thumb = client.resolve_thumbnail(&manifest).unwrap().expect("mesh thumbnail");
assert!(thumb.content.as_path().unwrap().exists());
// HEAD probe reports the exact size and echoes the identity as a strong
// ETag (the precondition for trusting a later If-Range resume).
let head_probe = client.blob_head(&manifest.files[0].blob).unwrap();
assert_eq!(head_probe.size, manifest.files[0].byte_len);
assert!(head_probe.etag_matches);
// Second resolve is served from cache: zero new blob requests.
let gets_before = fixture.log.count("GET", "/v1/blobs/");
let again = client
.resolve_file(
&manifest,
FileRole::RenderGlb,
TierPreference::PreferWithAnyFallback(DeviceTier::High),
7,
None,
)
.unwrap();
assert_eq!(again.content, resolved.content);
assert_eq!(fixture.log.count("GET", "/v1/blobs/"), gets_before, "cache miss on hot path");
// Bytes path agrees with file path.
let bytes = client
.resolve_file_bytes(
&manifest,
FileRole::RenderGlb,
TierPreference::PreferWithAnyFallback(DeviceTier::High),
7,
)
.unwrap();
assert_eq!(bytes, glb_bytes);
// Manifest re-fetch is also cache-served.
let revs_before = fixture.log.count("GET", "/v1/revisions/");
let _ = client.fetch_asset_manifest(&head.head_revision).unwrap();
assert_eq!(fixture.log.count("GET", "/v1/revisions/"), revs_before);
}
#[test]
fn range_resume_after_connection_kill() {
let mut store = FixtureStore::default();
let big = payload(555, 300_000);
let r = store.add_prop(50, "stock", None, "Big Rocket", big.clone(), vec![]);
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("resume"), fixture.endpoints(), None).unwrap();
let manifest = client.fetch_asset_manifest(&r.revision).unwrap();
let file = manifest.files[0].clone();
// First attempt dies after ~90KB of body.
*fixture.knobs.kill_blob_after.lock().unwrap() = Some(90_000);
let mut progress_points = Vec::new();
let mut progress = |bytes: u64, total: u64| progress_points.push((bytes, total));
let path = client
.fetch_blob(&file.blob, Some(file.byte_len), Some(&mut progress))
.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), big);
// Exactly two GETs: the killed one and the resume.
assert_eq!(fixture.log.count("GET", "/v1/blobs/"), 2);
let resume = fixture.log.last_matching("GET", "/v1/blobs/").unwrap();
let range = resume.header("range").expect("resume must use Range");
let start: u64 = range
.strip_prefix("bytes=")
.and_then(|r| r.strip_suffix('-'))
.and_then(|r| r.parse().ok())
.expect("open-ended range shape");
assert!(start > 0 && start < big.len() as u64, "resume offset {start}");
let if_range = resume.header("if-range").expect("resume must gate on If-Range");
assert_eq!(if_range, format!("\"{}\"", file.blob));
// Progress is monotonic and ends complete.
assert!(progress_points.windows(2).all(|w| w[0].0 <= w[1].0));
assert_eq!(progress_points.last().unwrap(), &(big.len() as u64, big.len() as u64));
}
#[test]
fn restart_resumes_partial_across_client_instances() {
let mut store = FixtureStore::default();
let big = payload(556, 200_000);
let r = store.add_prop(51, "stock", None, "Interrupted Rocket", big.clone(), vec![]);
let fixture = FixtureServer::start(store, FixtureOptions::default());
let root = test_root("restart_resume");
let file = {
let mut cfg = config("unused");
cfg.cache_root = root.clone();
cfg.max_transfer_attempts = 1; // fail hard on the kill
let mut client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
let manifest = client.fetch_asset_manifest(&r.revision).unwrap();
let file = manifest.files[0].clone();
*fixture.knobs.kill_blob_after.lock().unwrap() = Some(60_000);
let err = client.fetch_blob(&file.blob, Some(file.byte_len), None).unwrap_err();
assert!(matches!(err, ClientError::Io { .. }), "{err:?}");
file
// client drops; partial stays on disk
};
// A NEW client on the same cache root resumes instead of restarting.
let mut cfg = config("unused2");
cfg.cache_root = root;
let mut client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
let path = client.fetch_blob(&file.blob, Some(file.byte_len), None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), big);
let resume = fixture.log.last_matching("GET", "/v1/blobs/").unwrap();
assert!(resume.header("range").is_some(), "second process must resume, not restart");
}
#[test]
fn offline_resolution_from_cache_and_honest_miss() {
let (store, refs) = seeded_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("offline"), fixture.endpoints(), None).unwrap();
let manifest = client.fetch_asset_manifest(&refs[0].revision).unwrap();
let resolved = client
.resolve_file(
&manifest,
FileRole::RenderGlb,
TierPreference::PreferWithAnyFallback(DeviceTier::Low),
7,
None,
)
.unwrap();
// Server goes away entirely.
let mut fixture = fixture;
fixture.control.stop();
fixture.data.stop();
// Cached content still resolves, fully verified, with zero network.
let again = client
.resolve_file(
&manifest,
FileRole::RenderGlb,
TierPreference::PreferWithAnyFallback(DeviceTier::Low),
7,
None,
)
.unwrap();
assert_eq!(again.content, resolved.content);
let cached = client.cached_blob(&manifest.files[0].blob).unwrap();
assert!(cached.is_some());
// Uncached content fails with an explicit transport error — no guess, no
// fallback, no stale substitute.
let other = client.fetch_asset_manifest(&refs[5].revision);
match other {
Err(ClientError::Io { .. }) | Err(ClientError::Timeout { .. }) => {}
other => panic!("offline miss must be an explicit transport error: {other:?}"),
}
}
#[test]
fn pinning_survives_eviction_pressure_end_to_end() {
let mut store = FixtureStore::default();
let precious = payload(700, 30_000);
let precious_ref = store.add_prop(60, "stock", None, "Precious", precious.clone(), vec![]);
for i in 0..4u8 {
store.add_prop(61 + i, "stock", None, &format!("Filler {i}"), payload(710 + i as u64, 40_000), vec![]);
}
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut cfg = config("pin_pressure");
cfg.cache = CacheBudgets {
// Room for ~two big blobs (plus manifests/thumbs): heavy pressure.
max_total_bytes: 100_000,
max_object_bytes: 60_000,
max_partial_bytes: 100_000,
stale_partial_ms: 1_000_000,
max_ram_bytes: 512 * 1024,
};
let mut client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
let manifest = client.fetch_asset_manifest(&precious_ref.revision).unwrap();
let blob = manifest.files[0].blob;
client.pin_blob(&blob).unwrap();
client.fetch_blob(&blob, Some(manifest.files[0].byte_len), None).unwrap();
let gets_for_precious = fixture.log.count("GET", &format!("/v1/blobs/{blob}"));
// Churn: pull every filler through the small cache.
for i in 0..4u8 {
let asset_id = makepad_asset_data::AssetId::from_bytes([61 + i; 16]);
let detail = client.asset_detail(&asset_id).unwrap();
let rev = detail.latest_published().unwrap().revision;
let m = client.fetch_asset_manifest(&rev).unwrap();
client.fetch_blob(&m.files[0].blob, Some(m.files[0].byte_len), None).unwrap();
}
assert!(client.cache_stats().evictions > 0, "pressure never evicted anything");
// The pinned blob is still local — no refetch happened.
assert!(client.cached_blob(&blob).unwrap().is_some(), "pinned blob was evicted");
assert_eq!(
fixture.log.count("GET", &format!("/v1/blobs/{blob}")),
gets_for_precious,
"pinned blob was refetched"
);
}
#[test]
fn dependency_closure_bounded_and_verified() {
let mut store = FixtureStore::default();
let leaf = store.add_prop(72, "stock", None, "Leaf", payload(900, 800), vec![]);
let mid = store.add_prop(71, "stock", None, "Mid", payload(901, 800), vec![leaf]);
let root_ref = store.add_prop(70, "stock", None, "Root", payload(902, 800), vec![mid]);
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("closure"), fixture.endpoints(), None).unwrap();
let closure = client.resolve_closure(&root_ref, ClosureBudget::default()).unwrap();
let ids: Vec<_> = closure.iter().map(|(r, _)| r.asset_id).collect();
assert_eq!(ids.len(), 3);
assert_eq!(closure[0].0, root_ref, "BFS order starts at the root");
// Every manifest's declared pair was proven.
for (r, m) in &closure {
assert_eq!(m.asset_id, r.asset_id);
}
// Asset budget refusal.
let err = client
.resolve_closure(&root_ref, ClosureBudget { max_assets: 2, max_depth: 8 })
.unwrap_err();
assert!(matches!(err, ClientError::OverBudget { what: "closure assets", .. }), "{err:?}");
// Depth budget refusal.
let err = client
.resolve_closure(&root_ref, ClosureBudget { max_assets: 10, max_depth: 1 })
.unwrap_err();
assert!(matches!(err, ClientError::OverBudget { what: "closure depth", .. }), "{err:?}");
}
/// The bridge for tools that take a FILE rather than bytes (an AO bake, a
/// rig pass, an OS drag-out): a verified on-disk path for catalog content.
/// It stays thin-client-legal because the object is named by its digest and
/// re-hashed before the path is handed out — a materialisation of the
/// revision, never a second source of truth.
#[test]
fn blob_path_materialises_verified_content_and_re_fetches_a_corrupted_object() {
let mut store = FixtureStore::default();
let payload = vec![7u8; 60_000];
let blob = store.add_blob(payload.clone());
let other = store.add_blob(b"a second, different object".to_vec());
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("blob_path"), fixture.endpoints(), None).unwrap();
let path = client.blob_path(&blob, Some(payload.len() as u64)).unwrap();
assert!(path.is_file(), "the object is on disk at {}", path.display());
assert_eq!(std::fs::read(&path).unwrap(), payload, "and it is the real payload");
// Digest-keyed: the same blob resolves to the same path, and a second
// call is served from the cache rather than the network.
assert_eq!(client.blob_path(&blob, None).unwrap(), path);
assert_ne!(client.blob_path(&other, None).unwrap(), path, "distinct objects, distinct paths");
// A path is only handed out for bytes that still hash to the digest: a
// corrupted object is removed and re-fetched, never returned.
std::fs::write(&path, b"tampered").unwrap();
let again = client.blob_path(&blob, Some(payload.len() as u64)).unwrap();
assert_eq!(std::fs::read(&again).unwrap(), payload, "corruption re-fetched, not served");
// Asking for a path does not spend the RAM budget on a file the caller
// is about to read from disk.
client.clear_ram_cache();
let cold = client.blob_path(&blob, None).unwrap();
assert_eq!(std::fs::read(cold).unwrap(), payload);
assert_eq!(client.ram_cache_bytes().0, 0, "a path fetch stays out of RAM");
}
#[test]
fn ram_cache_evicts_under_its_budget_and_refetches_verified_after_forget() {
// Five blobs, a budget that fits two: the client must stay inside it
// and still answer every fetch with verified bytes.
let mut store = FixtureStore::default();
let payloads: Vec<Vec<u8>> = (0..5u8).map(|i| vec![i + 1; 40_000]).collect();
let blobs: Vec<BlobId> = payloads
.iter()
.map(|bytes| store.add_blob(bytes.clone()))
.collect();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut cfg = config("ram_budget");
cfg.cache.max_ram_bytes = 100_000;
let mut client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
for (blob, expect) in blobs.iter().zip(&payloads) {
let got = client.fetch_blob_bytes(blob, Some(expect.len() as u64)).unwrap();
assert_eq!(&got, expect, "every fetch is the real, verified payload");
let (used, budget) = client.ram_cache_bytes();
assert!(used <= budget, "ram cache blew its budget: {used} > {budget}");
}
let (used, budget) = client.ram_cache_bytes();
assert_eq!(budget, 100_000);
assert!(used <= budget && used > 0, "some residency, under budget: {used}");
// Forget drops residency; the next fetch re-materialises and re-verifies
// from the server (or the disk cache) rather than serving a ghost.
let hot = &blobs[4];
assert!(client.forget_blob(hot), "the newest fetch was resident");
let (after_forget, _) = client.ram_cache_bytes();
assert!(after_forget < used, "forget freed its bytes: {after_forget} !< {used}");
let again = client.fetch_blob_bytes(hot, Some(payloads[4].len() as u64)).unwrap();
assert_eq!(&again, &payloads[4], "re-fetch is verified, not a ghost");
// Clearing empties it without breaking any later fetch.
client.clear_ram_cache();
assert_eq!(client.ram_cache_bytes().0, 0);
let cold = client.fetch_blob_bytes(&blobs[0], Some(payloads[0].len() as u64)).unwrap();
assert_eq!(&cold, &payloads[0]);
}
#[test]
fn ram_cache_budget_holds_while_lanes_fetch_together() {
// Lane clones share one RAM cache. Eight threads pulling the same six
// blobs must never push it past the budget.
let mut store = FixtureStore::default();
let payloads: Vec<Vec<u8>> = (0..6u8).map(|i| vec![i + 9; 30_000]).collect();
let blobs: Vec<BlobId> = payloads
.iter()
.map(|bytes| store.add_blob(bytes.clone()))
.collect();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut cfg = config("ram_lanes");
cfg.cache.max_ram_bytes = 90_000;
let client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
std::thread::scope(|scope| {
for lane in 0..8 {
let mut lane_client = client.lane_clone();
let blobs = &blobs;
let payloads = &payloads;
scope.spawn(move || {
for round in 0..6 {
let i = (lane + round) % blobs.len();
let got = lane_client
.fetch_blob_bytes(&blobs[i], Some(payloads[i].len() as u64))
.unwrap();
assert_eq!(got, payloads[i]);
let (used, budget) = lane_client.ram_cache_bytes();
assert!(used <= budget, "lane saw {used} > {budget}");
}
});
}
});
let (used, budget) = client.ram_cache_bytes();
assert!(used <= budget, "after the lanes: {used} > {budget}");
}
#[test]
fn game_manifest_and_blob_fetch() {
let mut store = FixtureStore::default();
let game_rev = store.add_game(90, "Fixture Kart");
let splash_len = {
let bytes = store.game_manifests[game_rev.as_bytes()].clone();
let m = makepad_asset_data::GameRevisionManifest::from_canonical_bytes(&bytes).unwrap();
m.splash_byte_len
};
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("game"), fixture.endpoints(), None).unwrap();
let game = client.fetch_game_manifest(&game_rev).unwrap();
assert_eq!(game.name, "Fixture Kart");
// The splash blob materializes with its declared length verified.
let splash = client.fetch_blob_bytes(&game.splash_blob, Some(game.splash_byte_len)).unwrap();
assert_eq!(splash.len() as u64, splash_len);
// Lock blob has no declared length in the manifest: fetched under the
// budget cap, still digest-verified.
let lock = client.fetch_blob_bytes(&game.lock_blob, None).unwrap();
assert!(!lock.is_empty());
// Cached game manifest round-trips.
let again = client.fetch_game_manifest(&game_rev).unwrap();
assert_eq!(again, game);
}
#[test]
fn complete_partial_with_unknown_length_commits_via_416() {
// A fully downloaded partial whose expected length is unknown: the resume
// request lands at the server's size, gets 416, and the client PROVES the
// partial by digest locally instead of refetching anything.
let mut store = FixtureStore::default();
let bytes = payload(660, 50_000);
let r = store.add_prop(96, "stock", None, "Complete Partial", bytes.clone(), vec![]);
let blob = store.assets.iter().find(|a| a.asset_id == r.asset_id).unwrap().manifest.files[0]
.blob;
let fixture = FixtureServer::start(store, FixtureOptions::default());
let root = test_root("complete_partial");
{
let mut cache = makepad_asset_client::ContentCache::open(
&root,
CacheBudgets::default_v1(),
now_ms(),
)
.unwrap();
let mut w = cache.open_partial(blob.as_bytes()).unwrap();
w.write(&bytes).unwrap();
}
let mut cfg = config("unused3");
cfg.cache_root = root;
let mut client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
let path = client.fetch_blob(&blob, None, None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
// One request (the 416 probe), zero body bytes refetched.
assert_eq!(fixture.log.count("GET", "/v1/blobs/"), 1);
let probe = fixture.log.last_matching("GET", "/v1/blobs/").unwrap();
assert_eq!(
probe.header("range").unwrap(),
format!("bytes={}-", bytes.len()),
"probe must resume at the partial's end"
);
}
#[test]
fn runtime_states_are_explicit() {
let (store, refs) = seeded_store();
let fixture = FixtureServer::start(store, FixtureOptions::default());
let client = AssetClient::connect(config("runtime"), fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
// A search slot and a failing alias slot.
let search_id = runtime
.submit(ClientRequest::CatalogSearch {
query: CatalogQuery::text("rocket", 5),
cursor: None,
})
.unwrap();
let missing_alias = AssetAlias::from_str("stock/does-not-exist").unwrap();
let fail_id = runtime.submit(ClientRequest::ResolveAlias { alias: missing_alias }).unwrap();
let manifest_id = runtime
.submit(ClientRequest::FetchAssetManifest { rev: refs[0].revision })
.unwrap();
let mut search_slot: ResourceSlot<u64> = ResourceSlot::default();
search_slot.begin(search_id);
assert!(search_slot.state.is_loading());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut fail_seen = false;
let mut manifest_seen = false;
let mut started_order = Vec::new();
while !(search_slot.state.is_ready() && fail_seen && manifest_seen) {
assert!(std::time::Instant::now() < deadline, "runtime never finished");
for event in runtime.poll() {
if let ClientEvent::Started { id } = &event {
started_order.push(*id);
}
let consumed = search_slot.on_event(&event, |out| match out {
makepad_asset_client::ClientOutput::CatalogPage(p) => Some(p.total),
_ => None,
});
if consumed {
continue;
}
match event {
ClientEvent::Failed { id, error } if id == fail_id => {
assert!(matches!(error, ClientError::NotFound { .. }), "{error:?}");
fail_seen = true;
}
ClientEvent::Done { id, .. } if id == manifest_id => {
manifest_seen = true;
}
_ => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(search_slot.state.ready(), Some(&10u64));
// Lanes run requests in parallel, so completion order across requests is
// NOT a guarantee any more (tests/runtime_lanes.rs pins what is): every
// submitted request starts exactly once, and its own events stay ordered.
started_order.sort_unstable();
assert_eq!(started_order, vec![search_id, fail_id, manifest_id]);
runtime.shutdown();
}
#[test]
fn runtime_reports_blob_progress() {
let mut store = FixtureStore::default();
let big = payload(999, 1_500_000);
let r = store.add_prop(95, "stock", None, "Huge", big.clone(), vec![]);
let fixture = FixtureServer::start(store, FixtureOptions::default());
let mut client = AssetClient::connect(config("runtime_prog"), fixture.endpoints(), None).unwrap();
let manifest = client.fetch_asset_manifest(&r.revision).unwrap();
let file = manifest.files[0].clone();
let mut runtime = ClientRuntime::start(client).unwrap();
let id = runtime
.submit(ClientRequest::FetchBlob {
blob: file.blob,
expected_len: Some(file.byte_len),
pin: true,
})
.unwrap();
let mut progress = Vec::new();
let mut done_path = None;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while done_path.is_none() {
assert!(std::time::Instant::now() < deadline, "blob fetch never finished");
for event in runtime.poll() {
match event {
ClientEvent::Progress { id: eid, bytes, total } if eid == id => {
progress.push((bytes, total));
}
ClientEvent::Done { id: eid, output } if eid == id => match output {
makepad_asset_client::ClientOutput::Blob { content, .. } => {
done_path = Some(content.as_path().unwrap().to_path_buf());
}
other => panic!("wrong output: {other:?}"),
},
ClientEvent::Failed { id: eid, error } if eid == id => {
panic!("blob fetch failed: {error}");
}
_ => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(std::fs::read(done_path.unwrap()).unwrap(), big);
assert!(progress.len() >= 2, "throttled progress still reports interior points");
assert!(progress.windows(2).all(|w| w[0].0 <= w[1].0), "progress must be monotonic");
assert_eq!(progress.last().unwrap(), &(big.len() as u64, big.len() as u64));
runtime.shutdown();
}
#[test]
fn runtime_blob_pin_is_transactional_and_async_unpin_is_idempotent() {
let bytes = payload(1001, 48_000);
let mut store = FixtureStore::default();
let blob = store.add_blob(bytes.clone());
let fixture = FixtureServer::start(store, FixtureOptions::default());
let cfg = config("runtime_blob_lease");
let cache_root = cfg.cache_root.clone();
let client = AssetClient::connect(cfg, fixture.endpoints(), None).unwrap();
let mut runtime = ClientRuntime::start(client).unwrap();
let marker = pin_marker(&cache_root, &blob);
// A refusal must not leave the old pre-fetch pin marker behind.
let failed = runtime
.submit(ClientRequest::FetchBlob {
blob,
expected_len: Some(bytes.len() as u64 + 1),
pin: true,
})
.unwrap();
assert!(
matches!(wait_runtime(&mut runtime, failed), Err(ClientError::SizeMismatch { .. })),
"wrong declared length must refuse"
);
assert!(!marker.exists(), "failed fetch leaked a durable pin marker");
let fetched = runtime
.submit(ClientRequest::FetchBlob {
blob,
expected_len: Some(bytes.len() as u64),
pin: true,
})
.unwrap();
match wait_runtime(&mut runtime, fetched).expect("verified fetch") {
ClientOutput::Blob { blob: got, content } => {
assert_eq!(got, blob);
assert_eq!(std::fs::read(content.as_path().unwrap()).unwrap(), bytes);
}
other => panic!("wrong fetch output: {other:?}"),
}
assert!(marker.exists(), "successful pinned fetch did not pin its object");
for _ in 0..2 {
let unpin = runtime.submit(ClientRequest::UnpinBlob { blob }).unwrap();
match wait_runtime(&mut runtime, unpin).expect("async unpin") {
ClientOutput::BlobUnpinned { blob: got } => assert_eq!(got, blob),
other => panic!("wrong unpin output: {other:?}"),
}
assert!(!marker.exists(), "unpin left a durable pin marker");
}
runtime.shutdown();
}
// ---------------------------------------------------------------------------
// artifact publication (write path)
// ---------------------------------------------------------------------------
#[test]
fn publish_artifact_roundtrips_manifest_blobs_alias_and_annotation() {
use makepad_asset_client::{PublishFile, PublishRequest, PublishThumbnail};
use makepad_asset_data::{AssetKind, MediaType, ThumbnailMedia};
let token = format!("mpat_{}", "5c".repeat(32));
let fx = FixtureServer::start(
FixtureStore::default(),
FixtureOptions { auth_token: Some(token.clone()), ..FixtureOptions::default() },
);
let mut cfg = config("publish_rt");
cfg.token = Some(token);
let mut client = AssetClient::connect(cfg, fx.endpoints(), None).expect("connect");
let artifact = payload(31, 5_000);
let thumb = payload(32, 1_200);
let mut request = PublishRequest::new(
"gen",
AssetKind::Video,
"Neon drift",
PublishFile {
bytes: artifact.clone(),
media: MediaType::Mp4,
role: FileRole::Video,
media_millis: 5_200,
dims: None,
},
PublishThumbnail {
bytes: thumb.clone(),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
views: Vec::new(),
},
);
request.alias = Some(AssetAlias::from_str("gen/neon-drift").unwrap());
request.categories = vec!["music-video".into()];
request.prompt = "a neon drift over wet asphalt".into();
let published = client.publish_artifact(&request).expect("publish");
assert_eq!(published.alias.as_ref().unwrap().as_str(), "gen/neon-drift");
// The published revision fetches back, digest-verified + decoded, with
// the playable file and the mandatory thumbnail intact.
let manifest = client.fetch_asset_manifest(&published.revision).expect("manifest");
assert_eq!(manifest.asset_id, published.asset_id);
assert_eq!(manifest.kind, AssetKind::Video);
assert_eq!(manifest.files[0].blob, published.artifact_blob);
assert_eq!(manifest.thumbnail.as_ref().unwrap().blob, published.thumbnail_blob);
// The artifact bytes round-trip through the verified cache path.
let bytes = client
.fetch_blob_bytes(&published.artifact_blob, Some(artifact.len() as u64))
.expect("blob");
assert_eq!(bytes, artifact);
// The alias resolves to the published head; the detail reports it as the
// latest published candidate.
let alias = client.resolve_alias(request.alias.as_ref().unwrap()).expect("alias");
assert_eq!(alias.asset_id, published.asset_id);
assert_eq!(alias.head_revision, published.revision);
let detail = client.asset_detail(&published.asset_id).expect("detail");
assert_eq!(detail.latest_published().unwrap().revision, published.revision);
// The annotation landed (title recorded by the fixture's 204 route),
// and it landed BEFORE the publish (kind-stamped publish events).
assert_eq!(
fx.published.lock().unwrap().annotations.get(&published.asset_id.to_string()),
Some(&"Neon drift".to_string())
);
let log = fx.log.requests.lock().unwrap();
let idx_of = |m: &str, frag: &str| {
log.iter()
.position(|r| r.method == m && r.target.contains(frag))
.unwrap_or(usize::MAX)
};
assert!(
idx_of("PUT", "/annotation") < idx_of("POST", "/publish"),
"annotation must precede publish so the publish event carries the kind"
);
drop(log);
// Re-publish onto the SAME asset id (register 409 path): new revision,
// alias head moves.
let mut again = request.clone();
again.asset_id = Some(published.asset_id);
again.artifact.bytes[0] ^= 0xff;
let second = client.publish_artifact(&again).expect("re-publish");
assert_eq!(second.asset_id, published.asset_id);
assert_ne!(second.revision, published.revision);
let alias = client.resolve_alias(request.alias.as_ref().unwrap()).expect("alias 2");
assert_eq!(alias.head_revision, second.revision);
let detail = client.asset_detail(&published.asset_id).expect("detail 2");
assert_eq!(detail.latest_published().unwrap().revision, second.revision);
}
// ---------------------------------------------------------------------------
// runtime cancellation, cache-root locking, session, concurrent discovery
// ---------------------------------------------------------------------------
#[test]
fn runtime_cancel_skips_queued_and_aborts_in_flight() {
let mut store = FixtureStore::default();
// A blob large enough to drip for a while: 512KB in 32KB/25ms chunks
// (~400ms total) — cancellation lands mid-transfer deterministically.
let big = payload(700, 512 * 1024);
let big_id = store.add_blob(big.clone());
let small = payload(701, 20_000);
let small_id = store.add_blob(small.clone());
let fx = FixtureServer::start(store, FixtureOptions::default());
*fx.knobs.drip_blob.lock().unwrap() = Some((32 * 1024, 25));
let cfg = config("cancel_rt");
let cache_root = cfg.cache_root.clone();
let client = AssetClient::connect(cfg, fx.endpoints(), None).unwrap();
// This test is about the QUEUE: one worker per lane and both fetches
// pinned to the same lane, so the second is provably still queued when
// it is cancelled. (With the default pool it would simply run in
// parallel — which is the point of the lanes, proven in
// tests/runtime_lanes.rs.)
let mut runtime = ClientRuntime::start_with(
client,
RuntimeConfig { fast_workers: 1, bulk_workers: 1, ..RuntimeConfig::default_v1() },
)
.unwrap();
let id_big = runtime
.submit_with(
ClientRequest::FetchBlob {
blob: big_id,
expected_len: Some(big.len() as u64),
pin: true,
},
SubmitOptions::bulk(),
)
.unwrap();
let id_queued = runtime
.submit_with(
ClientRequest::FetchBlob {
blob: small_id,
expected_len: Some(small.len() as u64),
pin: true,
},
SubmitOptions::bulk(),
)
.unwrap();
// Give the worker time to start the drip transfer, then cancel BOTH:
// the in-flight one aborts between chunks, the queued one never starts.
std::thread::sleep(std::time::Duration::from_millis(120));
runtime.cancel(id_big);
runtime.cancel(id_queued);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut failures = std::collections::HashMap::new();
while failures.len() < 2 && std::time::Instant::now() < deadline {
for event in runtime.poll() {
if let ClientEvent::Failed { id, error } = event {
failures.insert(id, error);
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(
failures.get(&id_big),
Some(&ClientError::Cancelled),
"in-flight transfer must abort as Cancelled"
);
assert_eq!(
failures.get(&id_queued),
Some(&ClientError::Cancelled),
"queued request must be skipped as Cancelled"
);
assert!(
!pin_marker(&cache_root, &big_id).exists(),
"cancelled in-flight fetch leaked a durable pin marker"
);
assert!(
!pin_marker(&cache_root, &small_id).exists(),
"cancelled queued fetch leaked a durable pin marker"
);
// The aborted partial stays resumable: a fresh fetch (drip off)
// completes and verifies.
*fx.knobs.drip_blob.lock().unwrap() = None;
let id_retry = runtime
.submit(ClientRequest::FetchBlob {
blob: big_id,
expected_len: Some(big.len() as u64),
pin: false,
})
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut done = false;
while !done && std::time::Instant::now() < deadline {
for event in runtime.poll() {
match event {
ClientEvent::Done { id, .. } if id == id_retry => done = true,
ClientEvent::Failed { id, error } if id == id_retry => {
panic!("retry after cancel failed: {error}")
}
_ => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(done, "cancelled partial must resume cleanly");
}
#[test]
fn cache_root_is_process_exclusive_and_restart_clean() {
use makepad_asset_client::{CacheBudgets, ContentCache};
let root = test_root("cache_lock");
let first = ContentCache::open(&root, CacheBudgets::default_v1(), 1).expect("first open");
// A second live cache on the SAME root refuses — the single-owner
// contract is enforced, not just documented.
match ContentCache::open(&root, CacheBudgets::default_v1(), 2) {
Err(ClientError::CacheBusy) => {}
Err(other) => panic!("second owner must refuse with CacheBusy, got {other:?}"),
Ok(_) => panic!("second owner must refuse with CacheBusy, got Ok"),
}
// Releasing the first owner (clean or crashed process — the OS drops
// the lock either way) makes reopening immediate.
drop(first);
let again = ContentCache::open(&root, CacheBudgets::default_v1(), 3).expect("reopen");
drop(again);
}
#[test]
fn session_connector_hands_over_and_stops_cleanly() {
use makepad_asset_client::{SessionConfig, SessionConnector, SessionMsg, SessionStatus};
let fx = FixtureServer::start(FixtureStore::default(), FixtureOptions::default());
let mut cfg = SessionConfig::new(test_root("session_up"));
cfg.endpoints = Some(fx.endpoints());
cfg.media_lanes = vec!["video-a".into(), "video-b".into(), "audio".into()];
cfg.retry_min_ms = 100;
cfg.retry_max_ms = 200;
let mut connector = SessionConnector::start(cfg).expect("start");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut handles = None;
while handles.is_none() && std::time::Instant::now() < deadline {
for msg in connector.poll() {
if let SessionMsg::Up(up) = msg {
handles = Some(*up);
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let handles = handles.expect("session must come up against the fixture");
assert_eq!(handles.media.len(), 3, "one runtime per configured lane");
assert_eq!(handles.server_id, fx.options.server_id);
handles.shutdown();
// Wrong pinned identity: the connector reports honest retries and stop()
// ends it promptly.
let mut cfg = SessionConfig::new(test_root("session_pin"));
cfg.endpoints = Some(fx.endpoints());
cfg.server_id = Some([0x99; 16]);
cfg.retry_min_ms = 100;
cfg.retry_max_ms = 200;
let mut connector = SessionConnector::start(cfg).expect("start 2");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut retried = false;
while !retried && std::time::Instant::now() < deadline {
for msg in connector.poll() {
if let SessionMsg::Status(SessionStatus::Retrying { .. }) = msg {
retried = true;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(retried, "identity mismatch must surface as Retrying");
connector.stop();
// Config validation refuses duplicate/overlapping lanes.
let mut bad = SessionConfig::new(test_root("session_bad"));
bad.media_lanes = vec!["a".into(), "a".into()];
assert!(SessionConnector::start(bad).is_err());
}
#[cfg(unix)]
#[test]
fn discovery_listeners_share_the_port_on_one_host() {
// The regression this pins: two apps on one machine must BOTH be able
// to bind the discovery port (SO_REUSEADDR + SO_REUSEPORT). Beacons are
// broadcast on real LANs and reach every group member; loopback unicast
// delivery lands on at least one listener, which is asserted loosely.
let a = DiscoveryListener::start(0, 5_000, now_ms).expect("first listener");
let port = a.port();
let b = DiscoveryListener::start(port, 5_000, now_ms).expect(
"second listener on the SAME port must bind (reuse group)",
);
let fx = FixtureServer::start(FixtureStore::default(), FixtureOptions::default());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut seen = false;
while !seen && std::time::Instant::now() < deadline {
fx.send_beacon(port, false);
std::thread::sleep(std::time::Duration::from_millis(50));
let now = now_ms();
seen = !a.snapshot(now).is_empty() || !b.snapshot(now).is_empty();
}
assert!(seen, "a beacon must reach the reuse group");
}
// ---------------------------------------------------------------------------
// import + immutable derived-variant routes
//
// Real-process coverage of these routes belongs in the Asset Server crate
// (`libs/asset/store` e2e). This suite stays hermetic: a local TCP fixture
// that speaks the wire contract. A clean-checkout `cargo test` must not
// require a prebuilt `makepad-asset-store` binary.
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ImportTamper {
None,
SourceDigest,
ImportRevision,
ImportGetId,
ImportEntryRevision,
VariantBytes,
VariantEtag,
VariantSetId,
ResolveDigest,
SourcePageCursor,
SourcePageOrder,
}
struct ImportPlaneState {
write_token: String,
read_token: String,
server_id: [u8; 16],
sources: HashMap<String, (SourceCollectionId, Vec<u8>)>,
imports: HashMap<[u8; 32], (Vec<u8>, ImportReportDtoStored)>,
variants: HashMap<[u8; 32], Vec<u8>>,
sets: HashMap<[u8; 32], Vec<u8>>,
tamper: ImportTamper,
}
struct ImportReportDtoStored {
entries: Vec<(PackEntryKey, AssetId, AssetRevisionId, AssetAlias)>,
}
struct ImportPlane {
control: RawServer,
data: RawServer,
state: Arc<Mutex<ImportPlaneState>>,
write_token: String,
read_token: String,
}
impl ImportPlane {
fn start() -> ImportPlane {
Self::start_with_id([0x51; 16])
}
fn start_with_id(server_id: [u8; 16]) -> ImportPlane {
let write_token = format!("mpat_{}", "aa".repeat(32));
let read_token = format!("mpat_{}", "bb".repeat(32));
let state = Arc::new(Mutex::new(ImportPlaneState {
write_token: write_token.clone(),
read_token: read_token.clone(),
server_id,
sources: HashMap::new(),
imports: HashMap::new(),
variants: HashMap::new(),
sets: HashMap::new(),
tamper: ImportTamper::None,
}));
let control = {
let state = state.clone();
RawServer::start(Arc::new(move |req, stream| {
import_control(&state, &req, stream);
}))
};
let data = RawServer::start(Arc::new(|_req, stream| {
write_error(stream, 404, "not found");
}));
ImportPlane { control, data, state, write_token, read_token }
}
fn endpoints(&self) -> makepad_asset_client::ApiEndpoints {
makepad_asset_client::ApiEndpoints {
control: self.control.addr,
data: self.data.addr,
}
}
fn set_tamper(&self, tamper: ImportTamper) {
self.state.lock().unwrap().tamper = tamper;
}
fn seed_variant(&self, bytes: Vec<u8>) -> DerivedVariantId {
let id = DerivedVariantId::hash_of(&bytes);
self.state.lock().unwrap().variants.insert(*id.as_bytes(), bytes);
id
}
fn seed_source(&self, collection: SourceCollection) {
let bytes = collection.to_canonical_bytes().unwrap();
let digest = SourceCollectionId::hash_of(&bytes);
self.state
.lock()
.unwrap()
.sources
.insert(collection.id, (digest, bytes));
}
}
fn bearer_of(req: &ParsedRequest) -> Option<&str> {
req.header("authorization")
.and_then(|v| v.strip_prefix("Bearer "))
}
fn import_auth(state: &ImportPlaneState, req: &ParsedRequest) -> Result<bool, u16> {
// `true` = write principal, `false` = authenticated reader.
match bearer_of(req) {
Some(t) if t == state.write_token => Ok(true),
Some(t) if t == state.read_token => Ok(false),
_ => Err(401),
}
}
fn import_control(state: &Mutex<ImportPlaneState>, req: &ParsedRequest, stream: &mut TcpStream) {
let segs = req.segs();
let seg = |i: usize| segs.get(i).map(String::as_str).unwrap_or("");
if req.method == "GET" && seg(0) == "v1" && seg(1) == "health" {
let server_id = state.lock().unwrap().server_id;
write_json_resp(
stream,
200,
&obj(vec![
("server_id", s(common::hex(&server_id))),
("protocol_version", Value::Int(1)),
]),
);
return;
}
let write = {
let st = state.lock().unwrap();
match import_auth(&st, req) {
Ok(w) => w,
Err(401) => {
write_error(stream, 401, "unauthenticated");
return;
}
Err(_) => unreachable!(),
}
};
match (req.method.as_str(), seg(0), seg(1), segs.len()) {
("GET", "v1", "assets", 2) => {
write_json_resp(
stream,
200,
&obj(vec![("assets", Value::Arr(Vec::new())), ("cursor", Value::Null)]),
);
}
("PUT", "v1", "import-sources", 2) => {
if !write {
write_json_resp(
stream,
403,
&obj(vec![("error", s("denied")), ("capability", s("import_source"))]),
);
return;
}
let Ok(collection) = SourceCollection::from_canonical_bytes(&req.body) else {
write_error(stream, 400, "malformed source collection");
return;
};
let digest = SourceCollectionId::hash_of(&req.body);
let mut st = state.lock().unwrap();
if let Some((existing, _)) = st.sources.get(&collection.id) {
if *existing != digest {
write_error(stream, 409, "conflict");
return;
}
} else {
st.sources
.insert(collection.id.clone(), (digest, req.body.clone()));
}
let echoed = if st.tamper == ImportTamper::SourceDigest {
SourceCollectionId::from_bytes({
let mut b = *digest.as_bytes();
b[31] ^= 0xff;
b
})
} else {
digest
};
write_json_resp(
stream,
201,
&obj(vec![
("source_id", s(collection.id)),
("digest", s(echoed.to_string())),
]),
);
}
("GET", "v1", "import-sources", 2) => {
let explicit =
req.query_get("limit").is_some() || req.query_get("cursor").is_some();
let limit = if explicit {
match req.query_get("limit") {
None => 100usize,
Some(t) => {
if t.is_empty()
|| t.len() > 6
|| !t.bytes().all(|b| b.is_ascii_digit())
{
write_error(stream, 400, "malformed limit");
return;
}
let n: u64 = match t.parse() {
Ok(n) if n > 0 => n,
_ => {
write_error(stream, 400, "malformed limit");
return;
}
};
n.min(500) as usize
}
}
} else {
512
};
if let Some(c) = req.query_get("cursor") {
if !makepad_asset_client::wire::source_cursor_ok(&c) {
write_error(stream, 400, "malformed source cursor");
return;
}
}
let after = req.query_get("cursor");
let st = state.lock().unwrap();
let mut ids: Vec<_> = st.sources.keys().cloned().collect();
ids.sort();
if st.tamper == ImportTamper::SourcePageOrder {
ids.reverse();
}
let mut rows = Vec::new();
let mut last_source_id = None;
let mut more = false;
for id in ids {
if after.as_ref().is_some_and(|cursor| id.as_str() <= cursor.as_str()) {
continue;
}
if rows.len() == limit {
more = true;
break;
}
last_source_id = Some(id.clone());
let (digest, bytes) = st.sources.get(&id).unwrap();
let c = SourceCollection::from_canonical_bytes(bytes).unwrap();
rows.push(obj(vec![
("source_id", s(id)),
("title", s(c.title)),
("license", s(c.terms.license)),
("credits", s(c.terms.credits)),
("digest", s(digest.to_string())),
]));
}
if more && !explicit {
write_error(stream, 413, "source listing requires pagination");
return;
}
let cursor = match (more, last_source_id, st.tamper) {
(true, Some(id), ImportTamper::SourcePageCursor) => s(format!("{id}-x")),
(true, Some(id), _) => s(id),
_ => Value::Null,
};
write_json_resp(
stream,
200,
&obj(vec![("sources", Value::Arr(rows)), ("cursor", cursor)]),
);
}
("POST", "v1", "imports", 2) => {
if !write {
write_json_resp(
stream,
403,
&obj(vec![("error", s("denied")), ("capability", s("import_run"))]),
);
return;
}
let Ok(manifest) = ImportManifest::from_canonical_bytes(&req.body) else {
write_error(stream, 400, "malformed import");
return;
};
let irev = ImportRevisionId::hash_of(&req.body);
let mut st = state.lock().unwrap();
let (created, entries) = if let Some((_, stored)) = st.imports.get(irev.as_bytes()) {
(false, stored.entries.clone())
} else {
let Some((registered, registered_bytes)) = st.sources.get(&manifest.source_id) else {
write_error(stream, 404, "not found");
return;
};
if *registered != manifest.source_collection {
write_error(stream, 409, "conflict");
return;
}
let registered = SourceCollection::from_canonical_bytes(registered_bytes).unwrap();
if manifest.rights != registered.terms {
write_error(stream, 409, "conflict");
return;
}
let mut entries = Vec::new();
for asset in &manifest.assets {
let produced = manifest.asset_manifest_for(asset, &irev).unwrap();
let revision = produced.revision().unwrap();
entries.push((
asset.key.clone(),
produced.asset_id,
revision,
manifest.alias_for(&asset.key).unwrap(),
));
}
st.imports.insert(
*irev.as_bytes(),
(
req.body.clone(),
ImportReportDtoStored { entries: entries.clone() },
),
);
(true, entries)
};
let echoed = if st.tamper == ImportTamper::ImportRevision {
ImportRevisionId::from_bytes({
let mut b = *irev.as_bytes();
b[0] ^= 0xff;
b
})
} else {
irev
};
let rows: Vec<Value> = entries
.into_iter()
.map(|(key, asset_id, revision, alias)| {
let revision = if st.tamper == ImportTamper::ImportEntryRevision {
AssetRevisionId::from_bytes({
let mut b = *revision.as_bytes();
b[0] ^= 0xff;
b
})
} else {
revision
};
obj(vec![
("key", s(key.as_str().to_string())),
("asset_id", s(asset_id.to_string())),
("revision", s(revision.to_string())),
("alias", s(alias.as_str().to_string())),
])
})
.collect();
write_json_resp(
stream,
if created { 201 } else { 200 },
&obj(vec![
("import_revision", s(echoed.to_string())),
("created", Value::Bool(created)),
("entries", Value::Arr(rows)),
]),
);
}
("GET", "v1", "imports", 3) => {
let Ok(irev) = seg(2).parse::<ImportRevisionId>() else {
write_error(stream, 400, "malformed import revision");
return;
};
let st = state.lock().unwrap();
let Some((bytes, stored)) = st.imports.get(irev.as_bytes()) else {
write_error(stream, 404, "not found");
return;
};
let manifest = ImportManifest::from_canonical_bytes(bytes).unwrap();
let echoed = if st.tamper == ImportTamper::ImportGetId {
ImportRevisionId::from_bytes([0xff; 32])
} else {
irev
};
let rows: Vec<Value> = stored
.entries
.iter()
.map(|(key, asset_id, revision, alias)| {
obj(vec![
("key", s(key.as_str().to_string())),
("asset_id", s(asset_id.to_string())),
("revision", s(revision.to_string())),
("alias", s(alias.as_str().to_string())),
])
})
.collect();
write_json_resp(
stream,
200,
&obj(vec![
("import_revision", s(echoed.to_string())),
("source_id", s(manifest.source_id)),
("pack_name", s(manifest.pack_name)),
("pack_version", s(manifest.pack_version)),
("license", s(manifest.rights.license)),
("credits", s(manifest.rights.credits)),
("entries", Value::Arr(rows)),
]),
);
}
("GET", "v1", "derived-variants", 3) => {
let Ok(id) = seg(2).parse::<DerivedVariantId>() else {
write_error(stream, 400, "malformed derived variant");
return;
};
let st = state.lock().unwrap();
let Some(mut bytes) = st.variants.get(id.as_bytes()).cloned() else {
write_error(stream, 404, "not found");
return;
};
if st.tamper == ImportTamper::VariantBytes {
bytes[0] ^= 0xff;
}
let etag = if st.tamper == ImportTamper::VariantEtag {
format!("\"{}\"", DerivedVariantId::from_bytes([0x00; 32]))
} else {
format!("\"{id}\"")
};
write_bytes_resp(
stream,
200,
"application/octet-stream",
&bytes,
&[("ETag", &etag), ("Cache-Control", "private, max-age=31536000, immutable")],
);
}
("POST", "v1", "variant-sets", 2) => {
if !write {
write_json_resp(
stream,
403,
&obj(vec![("error", s("denied")), ("capability", s("asset_publish"))]),
);
return;
}
let Ok(body) = makepad_asset_client::json::parse(&req.body) else {
write_error(stream, 400, "malformed json");
return;
};
let Some(base_asset) = body
.get("base_asset")
.and_then(Value::as_str)
.and_then(|s| s.parse::<AssetId>().ok())
else {
write_error(stream, 400, "malformed asset id");
return;
};
let Some(base_revision) = body
.get("base_revision")
.and_then(Value::as_str)
.and_then(|s| s.parse::<AssetRevisionId>().ok())
else {
write_error(stream, 400, "malformed asset revision");
return;
};
let Some(arr) = body.get("variants").and_then(Value::as_arr) else {
write_error(stream, 400, "variants must be an array");
return;
};
let mut variants = Vec::new();
for v in arr {
let Some(id) = v.as_str().and_then(|t| t.parse::<DerivedVariantId>().ok()) else {
write_error(stream, 400, "malformed derived variant");
return;
};
variants.push(id);
}
let base = AssetRevisionRef { asset_id: base_asset, revision: base_revision };
let mut set = VariantSetManifest {
base,
variants: variants.clone(),
policy_version: RESOLUTION_POLICY_V1,
};
set.canonicalize();
let Ok(set_bytes) = set.to_canonical_bytes() else {
write_error(stream, 400, "invalid variant set");
return;
};
let set_id = VariantSetId::hash_of(&set_bytes);
let mut st = state.lock().unwrap();
for v in &set.variants {
let Some(bytes) = st.variants.get(v.as_bytes()) else {
write_error(stream, 404, "not found");
return;
};
let Ok(manifest) = DerivedVariantManifest::from_canonical_bytes(bytes) else {
write_error(stream, 400, "malformed derived variant");
return;
};
if manifest.base != base {
write_error(stream, 409, "conflict");
return;
}
}
st.sets.insert(*set_id.as_bytes(), set_bytes);
let echoed = if st.tamper == ImportTamper::VariantSetId {
VariantSetId::from_bytes({
let mut b = *set_id.as_bytes();
b[0] ^= 0xff;
b
})
} else {
set_id
};
write_json_resp(
stream,
201,
&obj(vec![("variant_set", s(echoed.to_string()))]),
);
}
("GET", "v1", "variant-sets", 3) => {
let Ok(id) = seg(2).parse::<VariantSetId>() else {
write_error(stream, 400, "malformed variant set");
return;
};
let st = state.lock().unwrap();
let Some(bytes) = st.sets.get(id.as_bytes()) else {
write_error(stream, 404, "not found");
return;
};
let etag = format!("\"{id}\"");
write_bytes_resp(
stream,
200,
"application/octet-stream",
bytes,
&[("ETag", &etag), ("Cache-Control", "private, max-age=31536000, immutable")],
);
}
("POST", "v1", "variant-resolutions", 2) => {
let Ok(body) = makepad_asset_client::json::parse(&req.body) else {
write_error(stream, 400, "malformed json");
return;
};
let Some(set_id) = body
.get("variant_set")
.and_then(Value::as_str)
.and_then(|s| s.parse::<VariantSetId>().ok())
else {
write_error(stream, 400, "malformed variant set");
return;
};
let Some(p) = body.get("profile") else {
write_error(stream, 400, "missing profile");
return;
};
let Some(tier) = p
.get("tier")
.and_then(Value::as_str)
.and_then(parse_tier_name)
else {
write_error(stream, 400, "malformed profile tier");
return;
};
let Some(accept) = p.get("accept").and_then(Value::as_arr) else {
write_error(stream, 400, "profile accept must be an array");
return;
};
let mut profile = ClientProfile {
policy_version: p
.get("policy_version")
.and_then(Value::as_u64)
.unwrap_or(RESOLUTION_POLICY_V1 as u64) as u32,
tier,
max_texture_dim: p.get("max_texture_dim").and_then(Value::as_u64).unwrap_or(0) as u32,
max_triangles: p.get("max_triangles").and_then(Value::as_u64).unwrap_or(0) as u32,
max_variant_bytes: p.get("max_variant_bytes").and_then(Value::as_u64).unwrap_or(0),
accept_png: false,
accept_jpeg: false,
accept_glb: false,
accept_bin: false,
};
for a in accept {
match a.as_str() {
Some("png") => profile.accept_png = true,
Some("jpeg") => profile.accept_jpeg = true,
Some("glb") => profile.accept_glb = true,
Some("bin") => profile.accept_bin = true,
_ => {
write_error(stream, 400, "malformed profile accept entry");
return;
}
}
}
let st = state.lock().unwrap();
let Some(set_bytes) = st.sets.get(set_id.as_bytes()) else {
write_error(stream, 404, "not found");
return;
};
let set = VariantSetManifest::from_canonical_bytes(set_bytes).unwrap();
let mut variants = Vec::new();
for v in &set.variants {
let Some(bytes) = st.variants.get(v.as_bytes()) else {
write_error(stream, 404, "not found");
return;
};
variants.push(DerivedVariantManifest::from_canonical_bytes(bytes).unwrap());
}
let map = resolve_variants(&set, &variants, &profile).unwrap();
let digest = if st.tamper == ImportTamper::ResolveDigest {
ResolvedMapDigest::from_bytes({
let mut b = *map.digest().unwrap().as_bytes();
b[0] ^= 0xff;
b
})
} else {
map.digest().unwrap()
};
let entries: Vec<Value> = map
.entries
.iter()
.map(|e| {
obj(vec![
(
"role",
s(makepad_asset_client::dto::variant_role_name(e.role)),
),
("variant", s(e.variant.to_string())),
(
"blobs",
Value::Arr(e.blobs.iter().map(|b| s(b.to_string())).collect()),
),
])
})
.collect();
write_json_resp(
stream,
200,
&obj(vec![
("digest", s(digest.to_string())),
("variant_set", s(map.set.to_string())),
("profile", s(map.profile.to_string())),
("entries", Value::Arr(entries)),
]),
);
}
_ => write_error(stream, 404, "not found"),
}
}
fn parse_tier_name(s: &str) -> Option<DeviceTier> {
Some(match s {
"any" => DeviceTier::Any,
"low" => DeviceTier::Low,
"medium" => DeviceTier::Medium,
"high" => DeviceTier::High,
_ => return None,
})
}
fn fixture_rights() -> Rights {
Rights {
license: "CC0-1.0".into(),
license_revision: String::new(),
terms_digest: Some(sha256(b"CC0-1.0 legal text")),
terms_url: "https://creativecommons.org/publicdomain/zero/1.0/".into(),
credits: "Kenney (kenney.nl)".into(),
source: "https://kenney.nl/assets/space-kit".into(),
source_archive: Some(sha256(b"space-kit-1.0.zip")),
redistribution: Redistribution::Allowed,
derivatives: DerivativePolicy::Allowed,
}
}
fn fixture_collection() -> SourceCollection {
SourceCollection {
id: "kenney".into(),
title: "Kenney game assets".into(),
origin: SourceOrigin::Upload,
terms: fixture_rights(),
}
}
fn fixture_import() -> ImportManifest {
let glb = b"watchtower-glb";
let preview = b"watchtower-png";
let mut manifest = ImportManifest {
source_collection: fixture_collection().digest().unwrap(),
source_id: "kenney".into(),
pack_name: "space-kit".into(),
pack_version: "1.0".into(),
policy_version: IMPORT_ASSET_ID_POLICY_V1,
assets: vec![ImportAsset {
key: "models/watchtower".parse().unwrap(),
kind: AssetKind::Prop,
files: vec![ImportFile {
path: "models/watchtower.glb".into(),
file: AssetFile {
role: FileRole::RenderGlb,
tier: DeviceTier::Any,
lod: 0,
media: MediaType::Glb,
blob: BlobId::hash_of(glb),
byte_len: glb.len() as u64,
dims: None,
},
}],
thumbnail: Some(ImportThumbnail {
path: "previews/watchtower.png".into(),
meta: ThumbnailMeta {
blob: BlobId::hash_of(preview),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
byte_len: preview.len() as u64,
views: Vec::new(),
},
}),
metrics: Metrics {
total_bytes: (glb.len() + preview.len()) as u64,
triangles: 12,
vertices: 8,
joints: 0,
clips: 0,
max_texture_dim: 0,
media_millis: 0,
},
coordinate_system: CoordinateSystem {
units_per_meter: 1.0,
up: Axis::YPos,
forward: Axis::ZNeg,
pivot: Pivot::Origin,
},
bounds: Bounds {
min: Vec3::new(-1.0, -1.0, -1.0),
max: Vec3::new(1.0, 1.0, 1.0),
},
anchors: vec![],
capabilities: Capabilities::default(),
spawn_recipe: None,
}],
rights: fixture_rights(),
};
manifest.canonicalize();
manifest
}
fn fixture_tool() -> ToolClosure {
ToolClosure {
processor: "mp_derive".into(),
version: "1.0".into(),
build: "deadbeef".into(),
deterministic: true,
}
}
fn fixture_thumb_variant(base: AssetRevisionRef) -> DerivedVariantManifest {
DerivedVariantManifest {
base,
kind: RecipeKind::MeshThumbnail,
recipe: ProcessingRecipe {
settings: RecipeSettings::MeshThumbnail {
width: 512,
height: 512,
media: ThumbnailMedia::Png,
},
tool: fixture_tool(),
output_schema: OUTPUT_SCHEMA_V1,
}
.digest()
.unwrap(),
inputs: vec![DerivedInput {
role: FileRole::RenderGlb,
blob: BlobId::hash_of(b"watchtower-glb"),
}],
outputs: vec![],
thumbnail: Some(ThumbnailMeta {
blob: BlobId::hash_of(b"derived-thumb"),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
byte_len: 450,
views: Vec::new(),
}),
metrics: Metrics {
total_bytes: 450,
..Default::default()
},
rights: fixture_rights(),
}
}
fn fixture_profile() -> ClientProfile {
ClientProfile {
policy_version: RESOLUTION_POLICY_V1,
tier: DeviceTier::High,
max_texture_dim: 2048,
max_triangles: 1_000_000,
max_variant_bytes: 64 * 1024 * 1024,
accept_png: true,
accept_jpeg: true,
accept_glb: true,
accept_bin: true,
}
}
fn import_config(name: &str, token: &str) -> ClientConfig {
let mut cfg = config(name);
cfg.token = Some(token.to_string());
cfg
}
fn connect_import(name: &str, plane: &ImportPlane, token: &str) -> AssetClient {
AssetClient::connect(import_config(name, token), plane.endpoints(), None).unwrap()
}
#[test]
fn import_source_register_list_and_idempotent_retry() {
let plane = ImportPlane::start();
let client = connect_import("src_reg", &plane, &plane.write_token);
let bytes = fixture_collection().to_canonical_bytes().unwrap();
let expected = SourceCollectionId::hash_of(&bytes);
let first = client.register_source_collection(&bytes).unwrap();
assert_eq!(first.source_id, "kenney");
assert_eq!(first.digest, expected);
let retry = client.register_source_collection(&bytes).unwrap();
assert_eq!(retry, first);
let listed = client.list_source_collections().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].source_id, "kenney");
assert_eq!(listed[0].digest, expected);
assert_eq!(listed[0].license, "CC0-1.0");
}
#[test]
fn import_run_status_and_idempotent_retry() {
let plane = ImportPlane::start();
let client = connect_import("imp_run", &plane, &plane.write_token);
let collection = fixture_collection().to_canonical_bytes().unwrap();
client.register_source_collection(&collection).unwrap();
let manifest = fixture_import();
let bytes = manifest.to_canonical_bytes().unwrap();
let expected = ImportRevisionId::hash_of(&bytes);
let first = client.run_import(&bytes).unwrap();
assert!(first.created);
assert_eq!(first.import_revision, expected);
assert_eq!(first.entries.len(), 1);
assert_eq!(first.entries[0].key.as_str(), "models/watchtower");
assert_eq!(first.entries[0].asset_id, manifest.asset_id_for(&manifest.assets[0].key));
let retry = client.run_import(&bytes).unwrap();
assert!(!retry.created);
assert_eq!(retry.import_revision, first.import_revision);
assert_eq!(retry.entries, first.entries);
let status = client.import_status(&expected).unwrap();
assert_eq!(status.import_revision, expected);
assert_eq!(status.source_id, "kenney");
assert_eq!(status.pack_name, "space-kit");
assert_eq!(status.entries[0].asset_id, first.entries[0].asset_id);
}
#[test]
fn derived_variant_and_variant_set_identities() {
let plane = ImportPlane::start();
let mut client = connect_import("dvar", &plane, &plane.write_token);
let base = AssetRevisionRef {
asset_id: AssetId::from_bytes([0x10; 16]),
revision: AssetRevisionId::from_bytes([0x20; 32]),
};
let variant = fixture_thumb_variant(base);
let variant_bytes = variant.to_canonical_bytes().unwrap();
let variant_id = plane.seed_variant(variant_bytes);
assert_eq!(variant_id, variant.id().unwrap());
let fetched = client.fetch_derived_variant(&variant_id).unwrap();
assert_eq!(fetched, variant);
let set_id = client.freeze_variant_set(&base, &[variant_id]).unwrap();
let retry = client.freeze_variant_set(&base, &[variant_id]).unwrap();
assert_eq!(retry, set_id);
let set = client.fetch_variant_set(&set_id).unwrap();
assert_eq!(set.base, base);
assert_eq!(set.variants, vec![variant_id]);
let map = client.resolve_variant_set(&set_id, &fixture_profile()).unwrap();
assert_eq!(map.set, set_id);
assert_eq!(map.profile, fixture_profile().digest().unwrap());
assert!(!map.entries.is_empty());
}
#[test]
fn import_derived_authorization() {
let plane = ImportPlane::start();
let collection = fixture_collection().to_canonical_bytes().unwrap();
let import_bytes = fixture_import().to_canonical_bytes().unwrap();
let writer = connect_import("auth_w", &plane, &plane.write_token);
writer.register_source_collection(&collection).unwrap();
writer.run_import(&import_bytes).unwrap();
let reader = connect_import("auth_r", &plane, &plane.read_token);
assert!(reader.list_source_collections().is_ok());
let irev = ImportRevisionId::hash_of(&import_bytes);
assert!(reader.import_status(&irev).is_ok());
match reader.register_source_collection(&collection) {
Err(ClientError::Denied) => {}
other => panic!("register must deny reader, got {other:?}"),
}
match reader.run_import(&import_bytes) {
Err(ClientError::Denied) => {}
other => panic!("import run must deny reader, got {other:?}"),
}
let base = AssetRevisionRef {
asset_id: AssetId::from_bytes([0x10; 16]),
revision: AssetRevisionId::from_bytes([0x20; 32]),
};
match reader.freeze_variant_set(&base, &[DerivedVariantId::from_bytes([1; 32])]) {
Err(ClientError::Denied) => {}
other => panic!("freeze must deny reader, got {other:?}"),
}
let anon = makepad_asset_client::Api::new(
plane.endpoints(),
fast_limits(),
None,
)
.unwrap();
match anon.list_source_collections() {
Err(ClientError::Unauthenticated) => {}
other => panic!("anonymous list must 401, got {other:?}"),
}
match anon.run_import(&import_bytes) {
Err(ClientError::Unauthenticated) => {}
other => panic!("anonymous import must 401, got {other:?}"),
}
}
#[test]
fn import_derived_tampered_identities_fail_closed() {
let plane = ImportPlane::start();
let client = connect_import("tamper", &plane, &plane.write_token);
let collection = fixture_collection().to_canonical_bytes().unwrap();
let import_bytes = fixture_import().to_canonical_bytes().unwrap();
let base = AssetRevisionRef {
asset_id: AssetId::from_bytes([0x10; 16]),
revision: AssetRevisionId::from_bytes([0x20; 32]),
};
let variant = fixture_thumb_variant(base);
let variant_id = plane.seed_variant(variant.to_canonical_bytes().unwrap());
plane.set_tamper(ImportTamper::SourceDigest);
match client.register_source_collection(&collection) {
Err(ClientError::DigestMismatch { what, .. }) => {
assert_eq!(what, "source collection digest")
}
other => panic!("tampered source digest must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::None);
client.register_source_collection(&collection).unwrap();
plane.set_tamper(ImportTamper::ImportRevision);
match client.run_import(&import_bytes) {
Err(ClientError::DigestMismatch { what, .. }) => assert_eq!(what, "import revision"),
other => panic!("tampered import revision must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::ImportEntryRevision);
match client.run_import(&import_bytes) {
Err(ClientError::DigestMismatch { what, .. }) => {
assert_eq!(what, "import entry revision")
}
other => panic!("tampered entry revision must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::None);
let report = client.run_import(&import_bytes).unwrap();
plane.set_tamper(ImportTamper::ImportGetId);
match client.import_status(&report.import_revision) {
Err(ClientError::Protocol { what }) => assert_eq!(what, "import status id mismatch"),
other => panic!("tampered import get id must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::None);
let mut fetch_client = connect_import("tamper_fetch", &plane, &plane.write_token);
plane.set_tamper(ImportTamper::VariantBytes);
match fetch_client.fetch_derived_variant(&variant_id) {
Err(ClientError::DigestMismatch { what, .. }) => {
assert_eq!(what, "derived variant bytes")
}
other => panic!("tampered variant bytes must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::VariantEtag);
match fetch_client.fetch_derived_variant(&variant_id) {
Err(ClientError::Protocol { what }) => assert_eq!(what, "canonical etag mismatch"),
other => panic!("tampered variant etag must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::VariantSetId);
match client.freeze_variant_set(&base, &[variant_id]) {
Err(ClientError::DigestMismatch { what, .. }) => {
assert_eq!(what, "variant set identity")
}
other => panic!("tampered variant set id must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::None);
let set_id = client.freeze_variant_set(&base, &[variant_id]).unwrap();
plane.set_tamper(ImportTamper::ResolveDigest);
match client.resolve_variant_set(&set_id, &fixture_profile()) {
Err(ClientError::DigestMismatch { what, .. }) => {
assert_eq!(what, "resolution map digest")
}
other => panic!("tampered resolve digest must refuse, got {other:?}"),
}
}
#[test]
fn import_derived_runtime_request_output_mapping() {
let plane = ImportPlane::start();
let client = connect_import("rt_map", &plane, &plane.write_token);
let collection = fixture_collection().to_canonical_bytes().unwrap();
let import_bytes = fixture_import().to_canonical_bytes().unwrap();
let base = AssetRevisionRef {
asset_id: AssetId::from_bytes([0x10; 16]),
revision: AssetRevisionId::from_bytes([0x20; 32]),
};
let variant = fixture_thumb_variant(base);
let variant_id = plane.seed_variant(variant.to_canonical_bytes().unwrap());
let mut runtime = ClientRuntime::start(client).unwrap();
let id_reg = runtime
.submit(ClientRequest::RegisterSourceCollection {
bytes: collection.clone(),
})
.unwrap();
match wait_runtime(&mut runtime, id_reg).unwrap() {
ClientOutput::SourceCollectionRegistered(SourceCollectionRegistered { source_id, .. }) => {
assert_eq!(source_id, "kenney")
}
other => panic!("unexpected register output {other:?}"),
}
let id_list = runtime.submit(ClientRequest::ListSourceCollections).unwrap();
match wait_runtime(&mut runtime, id_list).unwrap() {
ClientOutput::SourceCollections(rows) => assert_eq!(rows.len(), 1),
other => panic!("unexpected list output {other:?}"),
}
let id_run = runtime
.submit(ClientRequest::RunImport {
bytes: import_bytes.clone(),
})
.unwrap();
let report = match wait_runtime(&mut runtime, id_run).unwrap() {
ClientOutput::ImportReport(r) => r,
other => panic!("unexpected import output {other:?}"),
};
let id_status = runtime
.submit(ClientRequest::FetchImport {
revision: report.import_revision,
})
.unwrap();
match wait_runtime(&mut runtime, id_status).unwrap() {
ClientOutput::ImportStatus(s) => assert_eq!(s.import_revision, report.import_revision),
other => panic!("unexpected status output {other:?}"),
}
let id_var = runtime
.submit(ClientRequest::FetchDerivedVariant { id: variant_id })
.unwrap();
match wait_runtime(&mut runtime, id_var).unwrap() {
ClientOutput::DerivedVariant(v) => assert_eq!(v.id().unwrap(), variant_id),
other => panic!("unexpected derived output {other:?}"),
}
let id_freeze = runtime
.submit(ClientRequest::FreezeVariantSet {
base,
variants: vec![variant_id],
})
.unwrap();
let set_id = match wait_runtime(&mut runtime, id_freeze).unwrap() {
ClientOutput::VariantSetFrozen(id) => id,
other => panic!("unexpected freeze output {other:?}"),
};
let id_set = runtime
.submit(ClientRequest::FetchVariantSet { id: set_id })
.unwrap();
match wait_runtime(&mut runtime, id_set).unwrap() {
ClientOutput::VariantSet(set) => assert_eq!(set.id().unwrap(), set_id),
other => panic!("unexpected set output {other:?}"),
}
let id_res = runtime
.submit(ClientRequest::ResolveVariantSet {
set: set_id,
profile: fixture_profile(),
})
.unwrap();
match wait_runtime(&mut runtime, id_res).unwrap() {
ClientOutput::ResolvedVariants(map) => assert_eq!(map.set, set_id),
other => panic!("unexpected resolve output {other:?}"),
}
runtime.shutdown();
}
fn seed_n_sources(plane: &ImportPlane, n: usize) {
for i in 0..n {
plane.seed_source(SourceCollection {
id: format!("s{i:03}"),
title: format!("Source {i:03}"),
origin: SourceOrigin::Upload,
terms: fixture_rights(),
});
}
}
#[test]
fn source_collections_page_cursor_order_and_limit() {
let plane = ImportPlane::start();
seed_n_sources(&plane, 5);
let client = connect_import("src_page", &plane, &plane.write_token);
match client.source_collections_page(None, 0) {
Err(ClientError::InvalidInput { what }) => assert_eq!(what, "source page limit"),
other => panic!("limit 0 must be local, got {other:?}"),
}
match client.source_collections_page(None, 501) {
Err(ClientError::InvalidInput { what }) => assert_eq!(what, "source page limit"),
other => panic!("limit 501 must be local, got {other:?}"),
}
let first = client.source_collections_page(None, 2).unwrap();
assert_eq!(
first.sources.iter().map(|r| r.source_id.as_str()).collect::<Vec<_>>(),
["s000", "s001"]
);
assert_eq!(first.next.as_ref().map(|c| c.server_id()), Some(client.server_id()).as_ref());
let second = client
.source_collections_page(first.next.as_ref(), 2)
.unwrap();
assert_eq!(
second.sources.iter().map(|r| r.source_id.as_str()).collect::<Vec<_>>(),
["s002", "s003"]
);
let last = client
.source_collections_page(second.next.as_ref(), 2)
.unwrap();
assert_eq!(
last.sources.iter().map(|r| r.source_id.as_str()).collect::<Vec<_>>(),
["s004"]
);
assert!(last.next.is_none());
let all = client.list_source_collections().unwrap();
assert_eq!(all.len(), 5);
let mut sorted = all.clone();
sorted.sort_by(|a, b| a.source_id.cmp(&b.source_id));
assert_eq!(all, sorted);
}
#[test]
fn source_collections_page_tamper_and_legacy_aggregate() {
let plane = ImportPlane::start();
seed_n_sources(&plane, 5);
let client = connect_import("src_tamper", &plane, &plane.write_token);
plane.set_tamper(ImportTamper::SourcePageOrder);
match client.source_collections_page(None, 3) {
Err(ClientError::Protocol { what }) => assert_eq!(what, "source collection order"),
other => panic!("reversed page must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::SourcePageCursor);
match client.source_collections_page(None, 2) {
Err(ClientError::Protocol { what }) => {
assert!(what == "source page cursor" || what == "source page cursor mismatch")
}
other => panic!("tampered cursor must refuse, got {other:?}"),
}
plane.set_tamper(ImportTamper::None);
seed_n_sources(&plane, 513);
match client.list_source_collections() {
Err(ClientError::OverBudget { what, limit, found }) => {
assert_eq!(what, "source collections");
assert_eq!(limit, 512);
assert!(found > 512);
}
other => panic!("513 sources must fail closed, not partial, got {other:?}"),
}
let exact = ImportPlane::start();
seed_n_sources(&exact, 512);
let client512 = connect_import("src_512", &exact, &exact.write_token);
assert_eq!(client512.list_source_collections().unwrap().len(), 512);
}
#[test]
fn source_collections_cursor_refuses_another_server() {
let a = ImportPlane::start_with_id([0x11; 16]);
let b = ImportPlane::start_with_id([0x22; 16]);
seed_n_sources(&a, 3);
seed_n_sources(&b, 3);
let ca = connect_import("src_srv_a", &a, &a.write_token);
let cb = connect_import("src_srv_b", &b, &b.write_token);
assert_ne!(ca.server_id(), cb.server_id());
let page = ca.source_collections_page(None, 2).unwrap();
let cursor = page.next.expect("continuation cursor");
assert_eq!(cursor.server_id(), &ca.server_id());
match cb.source_collections_page(Some(&cursor), 2) {
Err(ClientError::WrongServerCursor) => {}
other => panic!("foreign source cursor must refuse, got {other:?}"),
}
}
#[test]
fn source_page_over_ceiling_is_refused() {
let cap = makepad_asset_client::wire::MAX_SOURCE_PAGE_JSON_RESPONSE_BYTES;
let server = RawServer::start(Arc::new(move |_req, stream| {
let head = response_head(200, "application/json", cap + 1, &[]);
write_raw(stream, head.as_bytes());
}));
let api = makepad_asset_client::Api::new(
makepad_asset_client::ApiEndpoints {
control: server.addr,
data: server.addr,
},
fast_limits(),
None,
)
.unwrap();
match api.source_collections_page(None, 1) {
Err(ClientError::OverBudget { what, limit, found }) => {
assert_eq!(what, "json response body");
assert_eq!(limit, cap);
assert_eq!(found, cap + 1);
}
other => panic!("over-ceiling source page must refuse, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// side-channels: stems + lyrics attached to a published audio asset
// ---------------------------------------------------------------------------
#[test]
fn side_channels_attach_reuse_blobs_and_are_idempotent() {
use makepad_asset_client::side_channels::{SideChannelFile, SideChannelOutcome};
use makepad_asset_client::{PublishFile, PublishRequest, PublishThumbnail};
use makepad_asset_data::{AssetKind, MediaType, ThumbnailMedia};
let token = format!("mpat_{}", "6d".repeat(32));
let fx = FixtureServer::start(
FixtureStore::default(),
FixtureOptions { auth_token: Some(token.clone()), ..FixtureOptions::default() },
);
let mut cfg = config("side_channels");
cfg.token = Some(token);
let mut client = AssetClient::connect(cfg, fx.endpoints(), None).expect("connect");
// A published audio track.
let audio = payload(41, 9_000);
let request = PublishRequest::new(
"music",
AssetKind::Audio,
"Test Track",
PublishFile {
bytes: audio.clone(),
media: MediaType::Mp3,
role: FileRole::Audio,
media_millis: 187_000,
dims: None,
},
PublishThumbnail {
bytes: payload(42, 900),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
views: Vec::new(),
},
);
let published = client.publish_artifact(&request).expect("publish audio");
// Attach the four stems and the lyrics.
let stems: Vec<Vec<u8>> = (0..4).map(|i| payload(50 + i, 4_000 + i as usize)).collect();
let lyrics = br#"{"format":"vj-lyrics","version":4,"lines":[]}"#.to_vec();
let files = |stems: &[Vec<u8>], lyrics: &[u8]| -> Vec<SideChannelFile> {
let mut out: Vec<SideChannelFile> = FileRole::STEMS
.iter()
.zip(stems)
.map(|(role, bytes)| SideChannelFile {
role: *role,
media: MediaType::Ogg,
bytes: bytes.clone(),
})
.collect();
out.push(SideChannelFile {
role: FileRole::Lyrics,
media: MediaType::Json,
bytes: lyrics.to_vec(),
});
out
};
let outcome = client
.publish_side_channel_files(&published.asset_id, files(&stems, &lyrics))
.expect("attach side channels");
let revision = match outcome {
SideChannelOutcome::Published { revision } => revision,
other => panic!("expected a publish, got {other:?}"),
};
assert_ne!(revision, published.revision, "a NEW revision is the head");
// The head advanced and the manifest carries every role, with the audio
// blob REUSED (same id, no second upload of its bytes).
let detail = client.asset_detail(&published.asset_id).expect("detail");
assert_eq!(detail.latest_published().unwrap().revision, revision);
let manifest = client.fetch_asset_manifest(&revision).expect("manifest");
for role in FileRole::STEMS {
assert!(manifest.files.iter().any(|f| f.role == role), "{role:?} present");
}
assert!(manifest.files.iter().any(|f| f.role == FileRole::Lyrics));
let audio_file =
manifest.files.iter().find(|f| f.role == FileRole::Audio).expect("audio kept");
assert_eq!(audio_file.blob, published.artifact_blob, "audio blob reused");
// Total bytes still exactly the sum of the parts (validate enforces it,
// but assert the value moved).
let sum: u64 = manifest.files.iter().map(|f| f.byte_len).sum::<u64>()
+ manifest.thumbnail.as_ref().map(|t| t.byte_len).unwrap_or(0);
assert_eq!(manifest.metrics.total_bytes, sum);
// A stem blob round-trips through the verified cache path.
let stem_file =
manifest.files.iter().find(|f| f.role == FileRole::StemDrums).expect("drums");
let bytes =
client.fetch_blob_bytes(&stem_file.blob, Some(stem_file.byte_len)).expect("stem blob");
assert_eq!(bytes, stems[0]);
// Idempotence: a second identical attach is a no-op reporting the head.
let again = client
.publish_side_channel_files(&published.asset_id, files(&stems, &lyrics))
.expect("re-attach");
assert_eq!(again, SideChannelOutcome::AlreadyPresent { revision });
// A PARTIAL stem set refuses locally (contract: all four or none) on a
// FRESH asset — on the analysed one above it would be absorbed by role
// idempotence instead, which is also what a re-bake relies on.
let mut fresh = request.clone();
fresh.title = "Second Track".into();
fresh.artifact.bytes[0] ^= 0x55;
let second = client.publish_artifact(&fresh).expect("publish second audio");
let posts_before = fx.log.requests.lock().unwrap().len();
let partial: Vec<SideChannelFile> = FileRole::STEMS[..3]
.iter()
.zip(&stems)
.map(|(role, bytes)| SideChannelFile {
role: *role,
media: MediaType::Ogg,
bytes: bytes.iter().map(|b| b ^ 1).collect(),
})
.collect();
let err = client.publish_side_channel_files(&second.asset_id, partial);
assert!(err.is_err(), "partial stems must refuse: {err:?}");
let log = fx.log.requests.lock().unwrap();
assert!(
!log[posts_before..].iter().any(|r| r.method == "POST" && r.target.contains("/revisions")),
"no staging happened for the refused set"
);
}