44b3a7f02 added artist/artist_url/album/source_url/license/license_url to
the exported manifest.json search block, which is part of the hashed
snapshot payload -- the contract changed on purpose, so the deterministic
golden in deterministic_golden_rewrites_graph_and_indexes_every_route was
stale (expected 78d089..., actual 7331ed62...). Verified the export is
still deterministic (same hash across repeated runs) and updated the
golden constant, with a failure message that names the printed `left`
value as the new golden so the next bump is a one-liner.
809 lines
30 KiB
Rust
809 lines
30 KiB
Rust
mod common;
|
|
|
|
use common::*;
|
|
use makepad_asset_data::*;
|
|
use makepad_asset_store::json::Value;
|
|
use makepad_asset_store::*;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
struct ExportTransport {
|
|
root: std::path::PathBuf,
|
|
next: u64,
|
|
ready: VecDeque<makepad_asset_client::TransportCompletion>,
|
|
}
|
|
|
|
impl makepad_asset_client::Transport for ExportTransport {
|
|
fn start(&mut self, request: makepad_asset_client::OwnedRequest) -> makepad_asset_client::TransportId {
|
|
let id = makepad_asset_client::TransportId(self.next);
|
|
self.next += 1;
|
|
let route = request.url_or_target.split_once("/v1/")
|
|
.map(|(_, suffix)| format!("v1/{suffix}"))
|
|
.unwrap();
|
|
let result = std::fs::read(self.root.join(route))
|
|
.map(|body| makepad_asset_client::OwnedResponse {
|
|
status: 200,
|
|
headers: vec![("content-length".into(), body.len().to_string())],
|
|
body,
|
|
})
|
|
.map_err(|error| makepad_asset_client::TransportError::Network(error.to_string()));
|
|
self.ready.push_back(makepad_asset_client::TransportCompletion { id, result });
|
|
id
|
|
}
|
|
|
|
fn cancel(&mut self, id: makepad_asset_client::TransportId) {
|
|
self.ready.retain(|completion| completion.id != id);
|
|
}
|
|
|
|
fn poll(&mut self, out: &mut Vec<makepad_asset_client::TransportCompletion>) {
|
|
out.extend(self.ready.drain(..));
|
|
}
|
|
}
|
|
|
|
fn annotation(kind: AssetKind, title: &str) -> AssetAnnotation {
|
|
AssetAnnotation {
|
|
title: title.into(),
|
|
description: format!("Public description for {title}"),
|
|
kind: Some(kind),
|
|
categories: vec!["fixture".into()],
|
|
tags: vec!["public".into()],
|
|
creator: "Fixture Author".into(),
|
|
artist: String::new(),
|
|
artist_url: String::new(),
|
|
album: String::new(),
|
|
source_url: String::new(),
|
|
license: String::new(),
|
|
license_url: String::new(),
|
|
owner: Some(pid_n(9)),
|
|
generator: "fixture-generator".into(),
|
|
backend: "fixture-backend".into(),
|
|
model: "fixture-model".into(),
|
|
prompt: "SECRET PROMPT".into(),
|
|
provenance: "SECRET FREE FORM PROVENANCE".into(),
|
|
visibility: Visibility::Public,
|
|
}
|
|
}
|
|
|
|
fn publish_manifest(
|
|
core: &AssetServerCore,
|
|
namespace: &str,
|
|
alias: &str,
|
|
manifest: &AssetManifest,
|
|
now: u64,
|
|
) -> AssetRevisionId {
|
|
let bytes = manifest.to_canonical_bytes().unwrap();
|
|
let item = PublishBatchItem {
|
|
namespace: namespace.into(),
|
|
manifest_bytes: bytes,
|
|
annotation: annotation(manifest.kind, alias),
|
|
alias: Some(alias.parse().unwrap()),
|
|
};
|
|
core.publish_batch(&[item], now).unwrap()[0].revision
|
|
}
|
|
|
|
fn publish_manifest_without_alias(
|
|
core: &AssetServerCore,
|
|
namespace: &str,
|
|
manifest: &AssetManifest,
|
|
now: u64,
|
|
) -> AssetRevisionId {
|
|
let item = PublishBatchItem {
|
|
namespace: namespace.into(),
|
|
manifest_bytes: manifest.to_canonical_bytes().unwrap(),
|
|
annotation: annotation(manifest.kind, "unaliased fixture"),
|
|
alias: None,
|
|
};
|
|
core.publish_batch(&[item], now).unwrap()[0].revision
|
|
}
|
|
|
|
fn audio_manifest(id: AssetId, audio: &[u8], lyrics: Option<&[u8]>) -> AssetManifest {
|
|
let mut manifest = prop_manifest(id, b"unused", b"unused");
|
|
manifest.kind = AssetKind::Audio;
|
|
manifest.thumbnail = None;
|
|
manifest.files = vec![AssetFile {
|
|
role: FileRole::Audio,
|
|
tier: DeviceTier::Any,
|
|
lod: 0,
|
|
media: MediaType::Mp3,
|
|
blob: BlobId::hash_of(audio),
|
|
byte_len: audio.len() as u64,
|
|
dims: None,
|
|
}];
|
|
if let Some(lyrics) = lyrics {
|
|
manifest.files.push(AssetFile {
|
|
role: FileRole::Lyrics,
|
|
tier: DeviceTier::Any,
|
|
lod: 0,
|
|
media: MediaType::Json,
|
|
blob: BlobId::hash_of(lyrics),
|
|
byte_len: lyrics.len() as u64,
|
|
dims: None,
|
|
});
|
|
}
|
|
manifest.metrics = Metrics {
|
|
total_bytes: manifest.files.iter().map(|file| file.byte_len).sum(),
|
|
media_millis: 1_000,
|
|
..Default::default()
|
|
};
|
|
manifest.canonicalize();
|
|
manifest
|
|
}
|
|
|
|
/// Encode the v3 shape stored by pre-thumbnail-view servers. V4 appended one
|
|
/// empty view count after the five unchanged thumbnail fields.
|
|
fn content_v3_asset_bytes(manifest: &AssetManifest) -> Vec<u8> {
|
|
let thumbnail = manifest.thumbnail.as_ref().expect("fixture has a thumbnail");
|
|
assert!(thumbnail.views.is_empty());
|
|
let mut bytes = manifest.to_canonical_bytes().unwrap();
|
|
assert_eq!(&bytes[..7], b"MPC1\x01\x00\x04");
|
|
|
|
let offsets: Vec<_> = bytes
|
|
.windows(thumbnail.blob.as_bytes().len())
|
|
.enumerate()
|
|
.filter_map(|(offset, value)| (value == thumbnail.blob.as_bytes()).then_some(offset))
|
|
.collect();
|
|
assert_eq!(offsets.len(), 1, "thumbnail digest must be unique in fixture bytes");
|
|
let views_count = offsets[0] + 32 + 1 + 4 + 4 + 8;
|
|
assert_eq!(&bytes[views_count..views_count + 4], &[0; 4]);
|
|
bytes.drain(views_count..views_count + 4);
|
|
bytes[5..7].copy_from_slice(&3u16.to_be_bytes());
|
|
bytes
|
|
}
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
let mut out = String::with_capacity(bytes.len() * 2);
|
|
for byte in bytes {
|
|
use std::fmt::Write;
|
|
write!(out, "{byte:02x}").unwrap();
|
|
}
|
|
out
|
|
}
|
|
|
|
fn list_files(root: &Path) -> BTreeMap<String, Vec<u8>> {
|
|
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
|
|
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
|
.unwrap()
|
|
.map(|entry| entry.unwrap().path())
|
|
.collect();
|
|
entries.sort();
|
|
for path in entries {
|
|
if path.is_dir() {
|
|
walk(root, &path, out);
|
|
} else {
|
|
let relative = path.strip_prefix(root).unwrap();
|
|
let wire = format!("/{}", relative.to_string_lossy().replace('\\', "/"));
|
|
out.insert(wire, std::fs::read(path).unwrap());
|
|
}
|
|
}
|
|
}
|
|
let mut out = BTreeMap::new();
|
|
walk(root, root, &mut out);
|
|
out
|
|
}
|
|
|
|
fn parse_file(path: &Path) -> Value {
|
|
makepad_asset_store::json::parse(&std::fs::read(path).unwrap()).unwrap()
|
|
}
|
|
|
|
fn assert_no_forbidden_keys(value: &Value) {
|
|
const DENY: &[&str] = &[
|
|
"token",
|
|
"bearer",
|
|
"prompt",
|
|
"owner",
|
|
"owner_id",
|
|
"chat",
|
|
"rooms",
|
|
"jobs",
|
|
"operations",
|
|
"pipeline_events",
|
|
"blob_reference_path",
|
|
"import_history",
|
|
"provenance",
|
|
];
|
|
match value {
|
|
Value::Obj(pairs) => {
|
|
for (key, value) in pairs {
|
|
assert!(!DENY.contains(&key.as_str()), "forbidden JSON key {key}");
|
|
assert_no_forbidden_keys(value);
|
|
}
|
|
}
|
|
Value::Arr(values) => {
|
|
for value in values {
|
|
assert_no_forbidden_keys(value);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn manifest_file_records(manifest: &Value) -> Vec<&Value> {
|
|
manifest.get("files").unwrap().as_arr().unwrap().iter().collect()
|
|
}
|
|
|
|
fn field<'a>(value: &'a Value, key: &str) -> &'a str {
|
|
value.get(key).unwrap().as_str().unwrap()
|
|
}
|
|
|
|
fn assert_integrity_and_sanitization(out: &Path) {
|
|
let manifest = parse_file(&out.join("v1/static/manifest.json"));
|
|
assert_no_forbidden_keys(&manifest);
|
|
let mut recorded = BTreeSet::new();
|
|
for record in manifest_file_records(&manifest) {
|
|
let path = field(record, "path");
|
|
let bytes = std::fs::read(out.join(path.trim_start_matches('/'))).unwrap();
|
|
assert_eq!(record.get("byte_len").unwrap().as_u64(), Some(bytes.len() as u64));
|
|
assert_eq!(field(record, "sha256"), sha256_hex(&bytes));
|
|
recorded.insert(path.to_string());
|
|
if field(record, "content_type") == "application/json"
|
|
&& matches!(record.get("content_encoding"), Some(Value::Null))
|
|
{
|
|
assert_no_forbidden_keys(&makepad_asset_store::json::parse(&bytes).unwrap());
|
|
}
|
|
}
|
|
let all: BTreeSet<_> = list_files(out).into_keys().collect();
|
|
let unindexed: BTreeSet<_> = all.difference(&recorded).cloned().collect();
|
|
assert_eq!(
|
|
unindexed,
|
|
BTreeSet::from([
|
|
"/v1/static/manifest.json".to_string(),
|
|
"/v1/static/manifest.json.br".to_string(),
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_v3_asset_manifest_is_migrated_during_static_export() {
|
|
let (root, core) = open_core("static_export_content_v3");
|
|
let glb = b"V3-GLB";
|
|
let thumb = b"V3-PNG";
|
|
core.put_blob(glb, NOW).unwrap();
|
|
core.put_blob(thumb, NOW).unwrap();
|
|
let manifest = prop_manifest(asset_id_n(31), glb, thumb);
|
|
let v3_bytes = content_v3_asset_bytes(&manifest);
|
|
let migrated = AssetManifest::from_canonical_bytes(&v3_bytes).unwrap();
|
|
assert_eq!(migrated, manifest);
|
|
assert!(migrated.thumbnail.as_ref().unwrap().views.is_empty());
|
|
|
|
let old_revision = AssetRevisionId::hash_of(&v3_bytes);
|
|
let outcome = core
|
|
.publish_batch(
|
|
&[PublishBatchItem {
|
|
namespace: "music".into(),
|
|
manifest_bytes: v3_bytes,
|
|
annotation: annotation(AssetKind::Prop, "v3 fixture"),
|
|
alias: Some("music/v3-fixture".parse().unwrap()),
|
|
}],
|
|
NOW,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(outcome[0].revision, old_revision);
|
|
|
|
let reader = AssetServerCore::open_read_only(&root, Budgets::default_v1()).unwrap();
|
|
let out = root.join("export");
|
|
let report = export_static(&reader, &out, &StaticExportOptions::default()).unwrap();
|
|
assert_eq!((report.assets, report.revisions), (1, 1));
|
|
|
|
let migrated_bytes = migrated.to_canonical_bytes().unwrap();
|
|
let migrated_revision = AssetRevisionId::hash_of(&migrated_bytes);
|
|
assert_ne!(migrated_revision, old_revision);
|
|
let exported = std::fs::read(out.join(format!("v1/revisions/{migrated_revision}"))).unwrap();
|
|
assert_eq!(exported, migrated_bytes);
|
|
assert_eq!(u16::from_be_bytes([exported[5], exported[6]]), CONTENT_SCHEMA_VERSION);
|
|
|
|
drop(reader);
|
|
drop(core);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_schema_failure_names_table_and_does_not_print_usage() {
|
|
let (root, core) = open_core("static_export_content_schema_error");
|
|
let glb = b"FUTURE-GLB";
|
|
let thumb = b"FUTURE-PNG";
|
|
core.put_blob(glb, NOW).unwrap();
|
|
core.put_blob(thumb, NOW).unwrap();
|
|
let manifest = prop_manifest(asset_id_n(32), glb, thumb);
|
|
publish_manifest(&core, "music", "music/future-fixture", &manifest, NOW);
|
|
drop(core);
|
|
|
|
let mut future = manifest.to_canonical_bytes().unwrap();
|
|
let found = CONTENT_SCHEMA_VERSION + 1;
|
|
future[5..7].copy_from_slice(&found.to_be_bytes());
|
|
raw::exec(
|
|
&root.join("catalog.sqlite3"),
|
|
&format!("UPDATE asset_revisions SET manifest=X'{}'", hex(&future)),
|
|
);
|
|
|
|
let out = root.join("export");
|
|
let result = Command::new(env!("CARGO_BIN_EXE_makepad-asset-store"))
|
|
.arg("export-static")
|
|
.arg(&root)
|
|
.arg(&out)
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(result.status.code(), Some(1));
|
|
let stderr = String::from_utf8(result.stderr).unwrap();
|
|
assert!(stderr.contains(&format!(
|
|
"unsupported content schema in catalog.sqlite3 asset_revisions.manifest: expected {}..={}, found {found}",
|
|
MIN_READABLE_CONTENT_SCHEMA_VERSION, CONTENT_SCHEMA_VERSION,
|
|
)));
|
|
assert!(!stderr.contains("makepad-asset-store --root"));
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_golden_rewrites_graph_and_indexes_every_route() {
|
|
let (root, core) = open_core("static_export_golden");
|
|
let dep_glb = b"DEPENDENCY-GLB";
|
|
let dep_thumb = b"DEPENDENCY-PNG";
|
|
let root_glb = b"ROOT-GLB";
|
|
let root_thumb = b"ROOT-PNG";
|
|
for bytes in [dep_glb.as_slice(), dep_thumb, root_glb, root_thumb] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
|
|
let mut dependency = prop_manifest(asset_id_n(1), dep_glb, dep_thumb);
|
|
dependency.provenance = Some(Provenance {
|
|
generator: "secret-generator".into(),
|
|
model: "secret-model".into(),
|
|
version: "1".into(),
|
|
seed: 42,
|
|
parents: Vec::new(),
|
|
params_digest: Some(sha256(b"secret params")),
|
|
});
|
|
let dependency_old = publish_manifest(&core, "pub", "pub/fixture/dependency", &dependency, NOW);
|
|
|
|
let mut parent = prop_manifest(asset_id_n(2), root_glb, root_thumb);
|
|
parent.dependencies.push(AssetRevisionRef {
|
|
asset_id: dependency.asset_id,
|
|
revision: dependency_old,
|
|
});
|
|
parent.provenance = Some(Provenance {
|
|
generator: "private-pipeline".into(),
|
|
model: "private-model".into(),
|
|
version: "2".into(),
|
|
seed: 99,
|
|
parents: vec![dependency_old],
|
|
params_digest: None,
|
|
});
|
|
parent.canonicalize();
|
|
let parent_old = publish_manifest(&core, "pub", "pub/fixture/parent", &parent, NOW + 1);
|
|
|
|
let out_a = root.join("export-a");
|
|
let out_b = root.join("export-b");
|
|
let report_a = export_static(&core, &out_a, &StaticExportOptions::default()).unwrap();
|
|
let report_b = export_static(&core, &out_b, &StaticExportOptions::default()).unwrap();
|
|
assert_eq!(report_a, report_b);
|
|
assert_eq!(list_files(&out_a), list_files(&out_b));
|
|
assert_eq!(report_a.assets, 2);
|
|
assert_eq!(report_a.revisions, 2);
|
|
assert_eq!(report_a.aliases, 2);
|
|
assert_eq!(report_a.snapshot_id.len(), 32);
|
|
assert_eq!(
|
|
report_a.snapshot_id, "7331ed62a0e519599984c2ae4904f6a4",
|
|
"static export contract changed; if intentional, bump this golden to the `left` value printed above",
|
|
);
|
|
assert!(report_a.snapshot_id.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
|
|
|
let alias = parse_file(&out_a.join("v1/aliases/pub/fixture/parent"));
|
|
let parent_new: AssetRevisionId = field(&alias, "head_revision").parse().unwrap();
|
|
assert_ne!(parent_new, parent_old);
|
|
let parent_bytes = std::fs::read(out_a.join(format!("v1/revisions/{parent_new}"))).unwrap();
|
|
let parent_public = AssetManifest::from_canonical_bytes(&parent_bytes).unwrap();
|
|
assert!(parent_public.provenance.is_none());
|
|
assert_eq!(parent_public.rights, parent.rights);
|
|
assert_ne!(parent_public.dependencies[0].revision, dependency_old);
|
|
let dep_new = parent_public.dependencies[0].revision;
|
|
let dep_bytes = std::fs::read(out_a.join(format!("v1/revisions/{dep_new}"))).unwrap();
|
|
let dep_public = AssetManifest::from_canonical_bytes(&dep_bytes).unwrap();
|
|
assert!(dep_public.provenance.is_none());
|
|
assert_eq!(dep_public.rights.credits, "test");
|
|
assert_eq!(AssetRevisionId::hash_of(&parent_bytes), parent_new);
|
|
assert_eq!(AssetRevisionId::hash_of(&dep_bytes), dep_new);
|
|
|
|
let health = parse_file(&out_a.join("v1/health"));
|
|
let manifest = parse_file(&out_a.join("v1/static/manifest.json"));
|
|
assert_eq!(field(&health, "server_id"), field(&manifest, "server_id"));
|
|
assert_eq!(manifest.get("static_version").unwrap().as_u64(), Some(1));
|
|
assert_eq!(manifest.get("protocol_version").unwrap().as_u64(), Some(1));
|
|
assert_integrity_and_sanitization(&out_a);
|
|
|
|
// The producer's own public export is accepted by the thin-client
|
|
// reader before either side evolves independently.
|
|
let transport = ExportTransport { root: out_a.clone(), next: 1, ready: VecDeque::new() };
|
|
let mut reader = makepad_asset_client::StaticStore::start(
|
|
makepad_asset_client::BaseUrl::parse("https://fixture.invalid").unwrap(),
|
|
Box::new(transport),
|
|
Box::new(makepad_asset_client::MemoryCacheStore::new(1024 * 1024)),
|
|
).unwrap();
|
|
let events = reader.poll();
|
|
assert!(events.iter().any(|event| matches!(event, makepad_asset_client::StaticStoreEvent::Ready)));
|
|
assert_eq!(reader.assets_page(None, None, 10).unwrap().assets.len(), 2);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn forbidden_and_lan_local_rights_fail_closed() {
|
|
let (root, core) = open_core("static_export_rights");
|
|
for (id, policy, alias) in [
|
|
(3, Redistribution::Forbidden, "pub/rights/forbidden"),
|
|
(4, Redistribution::LanLocal, "pub/rights/lan-local"),
|
|
] {
|
|
let glb = format!("RIGHTS-GLB-{id}").into_bytes();
|
|
let thumb = format!("RIGHTS-PNG-{id}").into_bytes();
|
|
core.put_blob(&glb, NOW).unwrap();
|
|
core.put_blob(&thumb, NOW).unwrap();
|
|
let mut manifest = prop_manifest(asset_id_n(id), &glb, &thumb);
|
|
manifest.rights.redistribution = policy;
|
|
publish_manifest(&core, "pub", alias, &manifest, NOW + id as u64);
|
|
}
|
|
let out = root.join("export");
|
|
let report = export_static(&core, &out, &StaticExportOptions::default()).unwrap();
|
|
assert_eq!(report.assets, 0);
|
|
assert_eq!(report.aliases, 0);
|
|
assert_eq!(report.excluded_rights, 2);
|
|
let manifest = parse_file(&out.join("v1/static/manifest.json"));
|
|
assert!(manifest.get("assets").unwrap().as_arr().unwrap().is_empty());
|
|
assert!(!out.join("v1/aliases/pub/rights/forbidden").exists());
|
|
assert!(!out.join("v1/aliases/pub/rights/lan-local").exists());
|
|
assert_integrity_and_sanitization(&out);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
fn video_manifest(id: AssetId, video: &[u8], still: &[u8], thumb: &[u8]) -> AssetManifest {
|
|
let mut manifest = prop_manifest(id, video, thumb);
|
|
manifest.kind = AssetKind::Video;
|
|
manifest.files = vec![
|
|
AssetFile {
|
|
role: FileRole::PreviewFront,
|
|
tier: DeviceTier::Any,
|
|
lod: 0,
|
|
media: MediaType::Jpeg,
|
|
blob: BlobId::hash_of(still),
|
|
byte_len: still.len() as u64,
|
|
dims: Some(ImageDims { width: 32, height: 32 }),
|
|
},
|
|
AssetFile {
|
|
role: FileRole::Video,
|
|
tier: DeviceTier::Any,
|
|
lod: 0,
|
|
media: MediaType::Mp4,
|
|
blob: BlobId::hash_of(video),
|
|
byte_len: video.len() as u64,
|
|
dims: None,
|
|
},
|
|
];
|
|
manifest.metrics.total_bytes = (still.len() + video.len() + thumb.len()) as u64;
|
|
manifest.canonicalize();
|
|
manifest
|
|
}
|
|
|
|
fn blob_row<'a>(manifest: &'a Value, blob: &BlobId) -> &'a Value {
|
|
manifest
|
|
.get("blobs")
|
|
.unwrap()
|
|
.as_arr()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|row| field(row, "blob") == blob.to_string())
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn video_cap_omits_only_video_and_always_keeps_stills() {
|
|
let (root, core) = open_core("static_export_video");
|
|
let video = b"0123456789ABCDEF";
|
|
let still = b"STILL-JPEG";
|
|
let thumb = b"THUMB-PNG";
|
|
for bytes in [video.as_slice(), still.as_slice(), thumb.as_slice()] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
let manifest = video_manifest(asset_id_n(5), video, still, thumb);
|
|
publish_manifest(&core, "pub", "pub/video/clip", &manifest, NOW);
|
|
|
|
let out_small = root.join("export-small");
|
|
let small = StaticExportOptions {
|
|
include_video_up_to: video.len() as u64 - 1,
|
|
..StaticExportOptions::default()
|
|
};
|
|
export_static(&core, &out_small, &small).unwrap();
|
|
let index = parse_file(&out_small.join("v1/static/manifest.json"));
|
|
let video_id = BlobId::hash_of(video);
|
|
let still_id = BlobId::hash_of(still);
|
|
let thumb_id = BlobId::hash_of(thumb);
|
|
assert_eq!(blob_row(&index, &video_id).get("present").unwrap().as_bool(), Some(false));
|
|
assert_eq!(field(blob_row(&index, &video_id), "reason"), "video_cap");
|
|
assert!(!out_small.join(format!("v1/blobs/{video_id}")).exists());
|
|
assert!(out_small.join(format!("v1/blobs/{still_id}")).exists());
|
|
assert!(out_small.join(format!("v1/blobs/{thumb_id}")).exists());
|
|
|
|
let out_equal = root.join("export-equal");
|
|
let equal = StaticExportOptions {
|
|
include_video_up_to: video.len() as u64,
|
|
..StaticExportOptions::default()
|
|
};
|
|
export_static(&core, &out_equal, &equal).unwrap();
|
|
let index = parse_file(&out_equal.join("v1/static/manifest.json"));
|
|
assert_eq!(blob_row(&index, &video_id).get("present").unwrap().as_bool(), Some(true));
|
|
assert!(out_equal.join(format!("v1/blobs/{video_id}")).exists());
|
|
assert_integrity_and_sanitization(&out_small);
|
|
assert_integrity_and_sanitization(&out_equal);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn public_page_is_bounded_sorted_and_redacted_by_type() {
|
|
let (root, core) = open_core("static_export_page");
|
|
for id in [7, 6] {
|
|
let glb = [id, b'g'];
|
|
let thumb = [id, b't'];
|
|
core.put_blob(&glb, NOW).unwrap();
|
|
core.put_blob(&thumb, NOW).unwrap();
|
|
let manifest = prop_manifest(asset_id_n(id), &glb, &thumb);
|
|
publish_manifest(
|
|
&core,
|
|
"pub",
|
|
&format!("pub/page/asset-{id}"),
|
|
&manifest,
|
|
NOW + id as u64,
|
|
);
|
|
}
|
|
let first = core
|
|
.public_export_page(PublicExportFilter {
|
|
namespace: Some("pub"),
|
|
kind: Some(AssetKind::Prop),
|
|
after: None,
|
|
limit: 1,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(first.assets.len(), 1);
|
|
assert_eq!(first.assets[0].asset_id, asset_id_n(6));
|
|
assert_eq!(first.assets[0].search.title, "pub/page/asset-6");
|
|
assert!(first.assets[0]
|
|
.search
|
|
.terms
|
|
.iter()
|
|
.all(|term| !term.term.contains("secret")));
|
|
let second = core
|
|
.public_export_page(PublicExportFilter {
|
|
namespace: Some("pub"),
|
|
kind: Some(AssetKind::Prop),
|
|
after: first.next,
|
|
limit: 1,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(second.assets[0].asset_id, asset_id_n(7));
|
|
assert!(second.next.is_none());
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn namespace_kind_and_limit_include_unaliased_assets_and_side_channels_read_only() {
|
|
let (root, core) = open_core("static_export_filters");
|
|
let song_a = b"MP3-A";
|
|
let song_b = b"MP3-B";
|
|
let lyrics = br#"{"lines":[]}"#;
|
|
let prop = b"PROP-GLB";
|
|
let thumb = b"PROP-PNG";
|
|
for bytes in [
|
|
song_a.as_slice(),
|
|
song_b.as_slice(),
|
|
lyrics.as_slice(),
|
|
prop.as_slice(),
|
|
thumb.as_slice(),
|
|
] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
|
|
let audio_a = audio_manifest(asset_id_n(10), song_a, Some(lyrics));
|
|
let audio_b = audio_manifest(asset_id_n(11), song_b, None);
|
|
publish_manifest_without_alias(&core, "music", &audio_a, NOW);
|
|
publish_manifest_without_alias(&core, "music", &audio_b, NOW + 1);
|
|
let prop_manifest = prop_manifest(asset_id_n(12), prop, thumb);
|
|
publish_manifest(&core, "other", "other/fixture/prop", &prop_manifest, NOW + 2);
|
|
|
|
// The exporter opens a separate read-only WAL reader while the writer is
|
|
// still alive; it neither needs nor takes the host's server.lock.
|
|
let reader = AssetServerCore::open_read_only(&root, Budgets::default_v1()).unwrap();
|
|
|
|
let out_all = root.join("export-all");
|
|
let all = export_static(&reader, &out_all, &StaticExportOptions::default()).unwrap();
|
|
assert_eq!((all.assets, all.revisions, all.aliases), (3, 3, 1));
|
|
|
|
let out_ns = root.join("export-ns");
|
|
let by_ns = export_static(
|
|
&reader,
|
|
&out_ns,
|
|
&StaticExportOptions {
|
|
namespace: Some("music".into()),
|
|
..StaticExportOptions::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!((by_ns.assets, by_ns.revisions, by_ns.aliases), (2, 2, 0));
|
|
assert!(out_ns.join(format!("v1/blobs/{}", BlobId::hash_of(lyrics))).is_file());
|
|
|
|
let out_kind = root.join("export-kind");
|
|
let by_kind = export_static(
|
|
&reader,
|
|
&out_kind,
|
|
&StaticExportOptions {
|
|
kind: Some(AssetKind::Audio),
|
|
..StaticExportOptions::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!((by_kind.assets, by_kind.revisions), (2, 2));
|
|
|
|
let out_zero = root.join("export-zero");
|
|
let zero = export_static(
|
|
&reader,
|
|
&out_zero,
|
|
&StaticExportOptions {
|
|
namespace: Some("music".into()),
|
|
kind: Some(AssetKind::Prop),
|
|
..StaticExportOptions::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(zero.assets, 0);
|
|
assert_eq!(zero.live_assets_considered, 3);
|
|
assert_eq!(zero.excluded_namespace_mismatch, 1);
|
|
assert_eq!(zero.excluded_kind_mismatch, 2);
|
|
|
|
let out_limit = root.join("export-limit");
|
|
let limited = export_static(
|
|
&reader,
|
|
&out_limit,
|
|
&StaticExportOptions {
|
|
namespace: Some("music".into()),
|
|
limit: Some(1),
|
|
..StaticExportOptions::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!((limited.assets, limited.revisions), (1, 1));
|
|
assert_eq!(limited.excluded_limit, 1);
|
|
|
|
drop(reader);
|
|
drop(core);
|
|
raw::exec(&root.join("catalog.sqlite3"), "PRAGMA user_version=9");
|
|
let schema_v9_reader = AssetServerCore::open_read_only(&root, Budgets::default_v1()).unwrap();
|
|
assert_eq!(
|
|
schema_v9_reader
|
|
.public_export_page(PublicExportFilter {
|
|
namespace: Some("music"),
|
|
limit: 10,
|
|
..PublicExportFilter::default()
|
|
})
|
|
.unwrap()
|
|
.assets
|
|
.len(),
|
|
2
|
|
);
|
|
drop(schema_v9_reader);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn reachable_variant_documents_are_rewritten_to_digest_paths() {
|
|
let (root, core) = open_core("static_export_variants");
|
|
let glb = b"VARIANT-BASE-GLB";
|
|
let thumb = b"VARIANT-BASE-PNG";
|
|
core.put_blob(glb, NOW).unwrap();
|
|
core.put_blob(thumb, NOW).unwrap();
|
|
let mut manifest = prop_manifest(asset_id_n(8), glb, thumb);
|
|
manifest.provenance = Some(Provenance {
|
|
generator: "private-derivation-parent".into(),
|
|
model: "private-model".into(),
|
|
version: "1".into(),
|
|
seed: 7,
|
|
parents: Vec::new(),
|
|
params_digest: None,
|
|
});
|
|
let old_revision = publish_manifest(&core, "pub", "pub/variant/base", &manifest, NOW);
|
|
let base = AssetRevisionRef { asset_id: manifest.asset_id, revision: old_revision };
|
|
let recipe = ProcessingRecipe {
|
|
settings: RecipeSettings::MeshLod { lod: 1, target_triangles: 8 },
|
|
tool: ToolClosure {
|
|
processor: "mp_derive".into(),
|
|
version: "1.0".into(),
|
|
build: "deadbeef".into(),
|
|
deterministic: true,
|
|
},
|
|
output_schema: OUTPUT_SCHEMA_V1,
|
|
};
|
|
let DerivationOutcome::NeedsJob { dkey, job_id, .. } = core
|
|
.variants()
|
|
.begin_derivation(&base, &recipe.to_canonical_bytes().unwrap(), NOW + 1)
|
|
.unwrap()
|
|
else {
|
|
panic!("derivation did not arm");
|
|
};
|
|
let derived_bytes = b"VARIANT-LOD1-GLB";
|
|
core.put_blob(derived_bytes, NOW + 1).unwrap();
|
|
let result = DerivedResult {
|
|
outputs: vec![AssetFile {
|
|
role: FileRole::Lod1Glb,
|
|
tier: DeviceTier::Low,
|
|
lod: 1,
|
|
media: MediaType::Glb,
|
|
blob: BlobId::hash_of(derived_bytes),
|
|
byte_len: derived_bytes.len() as u64,
|
|
dims: None,
|
|
}],
|
|
thumbnail: None,
|
|
metrics: Metrics {
|
|
total_bytes: derived_bytes.len() as u64,
|
|
triangles: 6,
|
|
vertices: 5,
|
|
..Default::default()
|
|
},
|
|
};
|
|
let variant = core
|
|
.variants()
|
|
.complete_derivation(&dkey, &job_id, "fixture-worker", &result, NOW + 2)
|
|
.unwrap();
|
|
core.variants().freeze_variant_set(&base, &[variant], NOW + 3).unwrap();
|
|
|
|
let out = root.join("export");
|
|
export_static(&core, &out, &StaticExportOptions::default()).unwrap();
|
|
let index = parse_file(&out.join("v1/static/manifest.json"));
|
|
let variants = index.get("variants").unwrap().as_arr().unwrap();
|
|
assert_eq!(variants.len(), 1);
|
|
let new_revision: AssetRevisionId = field(&variants[0], "base_revision").parse().unwrap();
|
|
let new_set: VariantSetId = field(&variants[0], "variant_set").parse().unwrap();
|
|
let new_variant: DerivedVariantId = variants[0]
|
|
.get("variants")
|
|
.unwrap()
|
|
.as_arr()
|
|
.unwrap()[0]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
assert_ne!(new_revision, old_revision);
|
|
assert!(out.join(format!("v1/variant-sets/{new_set}")).is_file());
|
|
let variant_bytes = std::fs::read(out.join(format!("v1/derived-variants/{new_variant}"))).unwrap();
|
|
let public_variant = DerivedVariantManifest::from_canonical_bytes(&variant_bytes).unwrap();
|
|
assert_eq!(public_variant.base.revision, new_revision);
|
|
assert_eq!(DerivedVariantId::hash_of(&variant_bytes), new_variant);
|
|
assert!(out.join(format!("v1/blobs/{}", BlobId::hash_of(derived_bytes))).is_file());
|
|
assert_integrity_and_sanitization(&out);
|
|
|
|
let transport = ExportTransport { root: out.clone(), next: 1, ready: VecDeque::new() };
|
|
let mut reader = makepad_asset_client::StaticStore::start(
|
|
makepad_asset_client::BaseUrl::parse("https://fixture.invalid").unwrap(),
|
|
Box::new(transport),
|
|
Box::new(makepad_asset_client::MemoryCacheStore::new(1024 * 1024)),
|
|
).unwrap();
|
|
assert!(reader.poll().iter().any(|event| {
|
|
matches!(event, makepad_asset_client::StaticStoreEvent::Ready)
|
|
}));
|
|
let set_fetch = reader
|
|
.start_fetch(makepad_asset_client::StaticFetch::VariantSet(new_set))
|
|
.unwrap();
|
|
let variant_fetch = reader
|
|
.start_fetch(makepad_asset_client::StaticFetch::DerivedVariant(new_variant))
|
|
.unwrap();
|
|
let events = reader.poll();
|
|
assert!(events.iter().any(|event| matches!(
|
|
event,
|
|
makepad_asset_client::StaticStoreEvent::FetchDone {
|
|
id,
|
|
output: makepad_asset_client::StaticFetchOutput::VariantSet(_),
|
|
} if *id == set_fetch
|
|
)));
|
|
assert!(events.iter().any(|event| matches!(
|
|
event,
|
|
makepad_asset_client::StaticStoreEvent::FetchDone {
|
|
id,
|
|
output: makepad_asset_client::StaticFetchOutput::DerivedVariant(_),
|
|
} if *id == variant_fetch
|
|
)));
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|