makepad/libs/asset/store/tests/search.rs
Admin d24cbee60d asset platform: store pipelines with honest banded progress, vision annotation through the job queue, fleet scheduling (roles, spread, peer provisioning), the music contract, synonym search, the runs card
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- asset store: central vision-annotation queue; Kenney donate prompt
- asset-ai: vision domain — image + prompt -> text on every fleet node
- annotation runs the fleet's vision services through the normal job queue
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- store search: WordNet synonym expansion, query-side; per-term seeks
- video scheduling: a cold model pin never downloads past a warm one, and a dedicated box only serves its role
- an expansion can never lose a run — and `expand: true` finally means something
- fleet panel: a slow box is not a missing box
- h3: first+last keyframe conditioning (the weights were always FL2VA)
- registry: the three H3 FL2VA tiers name their real conditioning
- fleet scheduling: spread before stacking, and a stuck job moves
- a download never steals a job from a box that has the weights
- the faster GPU takes the tie: 6000 > 5090 > 4090
- an evicted flux model gives the card its VRAM back
- a job now records what each of its stages was handed
- open a stage in RUNS and read what it sent
- every expanded song was an instrumental: the lyrics had nowhere to go
- the writer never overwrites words the person wrote
- store: a dependent job's body is spliced from its deps' results at claim
- a fleet box's job row says what that job was asked for
- the store runs a whole pipeline, and one record says how far it got
- a client can declare a whole run, watch it, and stop it
- 100% now means published, and the expander is a job you can queue
- one card says what a spawned task is doing, everywhere
- a run that ends says so, instead of being noticed later
- when every box holding the model is busy, buy another copy
2026-08-29 09:26:25 +02:00

865 lines
36 KiB
Rust

//! Catalog search: annotation bounds, deterministic ranking, exact filters,
//! cursor pagination, restart persistence, transactional reindex, and the
//! authorization scope that keeps prompts out of hits/snippets/counts.
mod common;
use common::*;
use makepad_asset_store::{
AssetAnnotation, AssetServerCore, Budgets, SearchFilters, SearchQuery, SearchViewer,
ServerError, ViewerScope, Visibility,
};
use makepad_asset_data::{AssetId, AssetKind, AssetRevisionRef};
const ANYONE: SearchViewer<'static> = SearchViewer { principal: None, scope: ViewerScope::All };
fn ann(title: &str) -> AssetAnnotation {
AssetAnnotation {
title: title.into(),
description: String::new(),
kind: None,
categories: Vec::new(),
tags: Vec::new(),
creator: String::new(),
owner: None,
generator: String::new(),
backend: String::new(),
model: String::new(),
prompt: String::new(),
provenance: String::new(),
visibility: Visibility::Public,
}
}
fn reg(core: &AssetServerCore, n: u8, ns: &str) -> AssetId {
let id = asset_id_n(n);
core.catalog().register_asset(&id, ns, NOW).unwrap();
id
}
fn q<'a>(text: &'a str) -> SearchQuery<'a> {
SearchQuery {
text,
filters: SearchFilters::default(),
expand: false,
page_size: 10,
facets: 0,
}
}
#[test]
fn annotation_bounds_are_fail_closed() {
let (_root, core) = open_core("ann_bounds");
let search = core.search();
// Unregistered asset refuses.
assert!(matches!(
search.set_annotation(&asset_id_n(9), &ann("ghost"), NOW).unwrap_err(),
ServerError::NotFound { what: "asset for annotation" }
));
let id = reg(&core, 1, "rik2");
// Empty and oversize titles refuse.
assert!(matches!(
search.set_annotation(&id, &ann(""), NOW).unwrap_err(),
ServerError::InvalidInput { what: "annotation title empty" }
));
assert!(matches!(
search.set_annotation(&id, &ann(&"x".repeat(201)), NOW).unwrap_err(),
ServerError::OverBudget { what: "annotation title bytes", .. }
));
// Control characters in single-line fields refuse.
assert!(matches!(
search.set_annotation(&id, &ann("evil\x1b[31mtitle"), NOW).unwrap_err(),
ServerError::InvalidInput { what: "annotation title charset" }
));
// Oversize prompt refuses.
let mut a = ann("ok");
a.prompt = "p".repeat(8193);
assert!(matches!(
search.set_annotation(&id, &a, NOW).unwrap_err(),
ServerError::OverBudget { what: "annotation prompt bytes", .. }
));
// Too many tags / bad label charset refuse.
let mut a = ann("ok");
a.tags = (0..25).map(|i| format!("t{i}")).collect();
assert!(matches!(
search.set_annotation(&id, &a, NOW).unwrap_err(),
ServerError::OverBudget { what: "annotation tags", .. }
));
let mut a = ann("ok");
a.tags = vec!["Bad Tag".into()];
assert!(matches!(
search.set_annotation(&id, &a, NOW).unwrap_err(),
ServerError::InvalidInput { what: "annotation label charset" }
));
// Private without an owner refuses.
let mut a = ann("ok");
a.visibility = Visibility::Private;
assert!(matches!(
search.set_annotation(&id, &a, NOW).unwrap_err(),
ServerError::InvalidInput { what: "private annotation requires owner" }
));
// A valid annotation lands and round-trips, labels sorted+deduped.
let mut a = ann("Crystal Sword");
a.tags = vec!["weapon".into(), "crystal".into(), "weapon".into()];
a.description = "A fine blade".into();
search.set_annotation(&id, &a, NOW).unwrap();
let read = search.annotation(&id).unwrap().unwrap();
assert_eq!(read.title, "Crystal Sword");
assert_eq!(read.tags, vec!["crystal".to_string(), "weapon".to_string()]);
assert!(search.annotation(&asset_id_n(2)).unwrap().is_none());
}
#[test]
fn query_bounds_and_cursors_are_checked() {
let (_root, core) = open_core("query_bounds");
let search = core.search();
let id = reg(&core, 1, "rik2");
search.set_annotation(&id, &ann("alpha beta"), NOW).unwrap();
// Hostile query shapes refuse.
let big = "x".repeat(1025);
assert!(matches!(
search.search(&q(&big), &ANYONE, None).unwrap_err(),
ServerError::OverBudget { what: "search query bytes", .. }
));
let many: String = (0..33).map(|i| format!("t{i} ")).collect();
assert!(matches!(
search.search(&q(&many), &ANYONE, None).unwrap_err(),
ServerError::OverBudget { what: "search query terms", .. }
));
assert!(matches!(
search.search(&q("!!! ???"), &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "search query has no terms" }
));
let mut zq = q("alpha");
zq.page_size = 0;
assert!(matches!(
search.search(&zq, &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "search page size zero" }
));
zq.page_size = 101;
assert!(matches!(
search.search(&zq, &ANYONE, None).unwrap_err(),
ServerError::OverBudget { what: "search page size", .. }
));
// Cursors: malformed bytes, wrong version, and cross-query replay refuse.
assert!(matches!(
search.search(&q("alpha"), &ANYONE, Some(b"junk")).unwrap_err(),
ServerError::InvalidInput { what: "search cursor malformed" }
));
let page = search.search(&q("alpha"), &ANYONE, None).unwrap();
assert_eq!(page.hits.len(), 1);
assert!(page.cursor.is_none(), "single page needs no cursor");
// Force a cursor by page_size 1 over two matching assets.
let id2 = reg(&core, 2, "rik2");
search.set_annotation(&id2, &ann("alpha gamma"), NOW).unwrap();
let mut one = q("alpha");
one.page_size = 1;
let page = search.search(&one, &ANYONE, None).unwrap();
let cursor = page.cursor.clone().expect("more results exist");
// Same cursor against a different query text is stale.
let mut other = q("gamma");
other.page_size = 1;
assert!(matches!(
search.search(&other, &ANYONE, Some(&cursor)).unwrap_err(),
ServerError::InvalidInput { what: "stale search cursor" }
));
// Corrupt version byte is malformed.
let mut bad = cursor.clone();
bad[0] = 9;
assert!(matches!(
search.search(&one, &ANYONE, Some(&bad)).unwrap_err(),
ServerError::InvalidInput { what: "search cursor malformed" }
));
// The genuine cursor continues.
let page2 = search.search(&one, &ANYONE, Some(&cursor)).unwrap();
assert_eq!(page2.hits.len(), 1);
assert!(page2.cursor.is_none());
}
#[test]
fn ranking_is_field_weighted_and_deterministic() {
let (_root, core) = open_core("ranking");
let search = core.search();
let a = reg(&core, 1, "rik2");
let b = reg(&core, 2, "rik2");
let c = reg(&core, 3, "rik2");
// Same term in three fields of different weight.
search.set_annotation(&a, &ann("crystal sword"), NOW).unwrap();
let mut bd = ann("plain thing");
bd.description = "a crystal shard".into();
search.set_annotation(&b, &bd, NOW).unwrap();
let mut cd = ann("other thing");
cd.tags = vec!["crystal".into()];
search.set_annotation(&c, &cd, NOW).unwrap();
let page = search.search(&q("crystal"), &ANYONE, None).unwrap();
assert_eq!(page.total, 3);
let order: Vec<AssetId> = page.hits.iter().map(|h| h.asset_id).collect();
assert_eq!(order, vec![a, c, b], "title > tag > description weight");
assert!(page.hits[0].score > page.hits[1].score);
assert!(page.hits[1].score > page.hits[2].score);
// Multi-term queries are AND: only the asset with both terms matches.
let page = search.search(&q("crystal sword"), &ANYONE, None).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.hits[0].asset_id, a);
// Equal scores tie-break by asset_id ascending.
let d = reg(&core, 4, "rik2");
search.set_annotation(&d, &ann("crystal sword"), NOW).unwrap();
let page = search.search(&q("sword"), &ANYONE, None).unwrap();
assert_eq!(
page.hits.iter().map(|h| h.asset_id).collect::<Vec<_>>(),
vec![a, d]
);
}
#[test]
fn filters_are_exact_and_composable() {
let (_root, core) = open_core("filters");
let search = core.search();
let a = reg(&core, 1, "rik2");
let b = reg(&core, 2, "rik2");
let c = reg(&core, 3, "other");
let mut aa = ann("red robot");
aa.tags = vec!["mech".into()];
aa.categories = vec!["props".into()];
aa.creator = "rik".into();
aa.model = "flux-2".into();
search.set_annotation(&a, &aa, NOW).unwrap();
let mut ba = ann("blue robot");
ba.tags = vec!["organic".into()];
ba.creator = "ana".into();
search.set_annotation(&b, &ba, NOW).unwrap();
search.set_annotation(&c, &ann("green robot"), NOW).unwrap();
let hits = |filters: SearchFilters| {
let mut query = q("robot");
query.filters = filters;
let page = search.search(&query, &ANYONE, None).unwrap();
(page.hits.iter().map(|h| h.asset_id).collect::<Vec<_>>(), page.total)
};
assert_eq!(hits(SearchFilters { tag: Some("mech"), ..Default::default() }), (vec![a], 1));
assert_eq!(hits(SearchFilters { category: Some("props"), ..Default::default() }), (vec![a], 1));
assert_eq!(hits(SearchFilters { creator: Some("ana"), ..Default::default() }), (vec![b], 1));
assert_eq!(hits(SearchFilters { model: Some("flux-2"), ..Default::default() }), (vec![a], 1));
assert_eq!(hits(SearchFilters { namespace: Some("other"), ..Default::default() }), (vec![c], 1));
// Composed filters intersect; a non-matching composition is empty.
assert_eq!(
hits(SearchFilters { creator: Some("ana"), tag: Some("mech"), ..Default::default() }),
(vec![], 0)
);
// Hostile filter values refuse.
let mut query = q("robot");
query.filters = SearchFilters { tag: Some("Bad Tag"), ..Default::default() };
assert!(matches!(
search.search(&query, &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "annotation label charset" }
));
query.filters = SearchFilters { creator: Some(""), ..Default::default() };
assert!(matches!(
search.search(&query, &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "search filter empty" }
));
}
#[test]
fn exclude_tag_is_applied_server_side_and_composes() {
let (_root, core) = open_core("exclude_tag");
let search = core.search();
let a = reg(&core, 1, "rik2");
let b = reg(&core, 2, "rik2");
let c = reg(&core, 3, "rik2");
let tagged = |title: &str, tags: &[&str]| {
let mut x = ann(title);
x.tags = tags.iter().map(|t| (*t).to_string()).collect();
x
};
search.set_annotation(&a, &tagged("alpha robot", &["x"]), NOW).unwrap();
search.set_annotation(&b, &tagged("beta robot", &["x", "intermediate"]), NOW).unwrap();
search.set_annotation(&c, &tagged("gamma robot", &["intermediate"]), NOW).unwrap();
let hits = |filters: SearchFilters| {
let mut query = q("robot");
query.filters = filters;
let page = search.search(&query, &ANYONE, None).unwrap();
(page.hits.iter().map(|h| h.asset_id).collect::<Vec<_>>(), page.total)
};
assert_eq!(hits(SearchFilters::default()), (vec![a, b, c], 3));
// Exclusion alone: total is cut by the same clause as the page.
assert_eq!(
hits(SearchFilters { exclude_tag: Some("intermediate"), ..Default::default() }),
(vec![a], 1)
);
// b carries both labels, so the exclusion overrides the positive match.
assert_eq!(hits(SearchFilters { tag: Some("x"), ..Default::default() }), (vec![a, b], 2));
assert_eq!(
hits(SearchFilters {
tag: Some("x"),
exclude_tag: Some("intermediate"),
..Default::default()
}),
(vec![a], 1)
);
// An unused label excludes nothing.
assert_eq!(
hits(SearchFilters { exclude_tag: Some("nosuch"), ..Default::default() }),
(vec![a, b, c], 3)
);
// Same label validation as `tag`.
let mut bad = q("robot");
bad.filters = SearchFilters { exclude_tag: Some("Bad Tag"), ..Default::default() };
assert!(matches!(
search.search(&bad, &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "annotation label charset" }
));
bad.filters = SearchFilters { exclude_tag: Some(""), ..Default::default() };
assert!(matches!(
search.search(&bad, &ANYONE, None).unwrap_err(),
ServerError::InvalidInput { what: "annotation label length" }
));
// Keyset paging with excluded rows interleaved between kept ones:
// ids 1,4,6 survive, 2,3,5 drop, one hit per page.
let d = reg(&core, 4, "rik2");
let e = reg(&core, 5, "rik2");
let g = reg(&core, 6, "rik2");
search.set_annotation(&d, &tagged("delta robot", &["x"]), NOW).unwrap();
search.set_annotation(&e, &tagged("epsilon robot", &["intermediate"]), NOW).unwrap();
search.set_annotation(&g, &tagged("zeta robot", &["x"]), NOW).unwrap();
let mut paged = q("robot");
paged.page_size = 1;
paged.filters = SearchFilters { exclude_tag: Some("intermediate"), ..Default::default() };
let mut seen = Vec::new();
let mut cursor: Option<Vec<u8>> = None;
for _ in 0..10 {
let page = search.search(&paged, &ANYONE, cursor.as_deref()).unwrap();
assert_eq!(page.total, 3, "the COUNT form excludes the same rows");
seen.extend(page.hits.iter().map(|h| h.asset_id));
match page.cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(seen, vec![a, d, g]);
// The exclusion is part of the cursor fingerprint: a cursor minted under
// it is stale against the same text without it.
let first = search.search(&paged, &ANYONE, None).unwrap().cursor.expect("more pages");
let mut without = q("robot");
without.page_size = 1;
assert!(matches!(
search.search(&without, &ANYONE, Some(&first)).unwrap_err(),
ServerError::InvalidInput { what: "stale search cursor" }
));
}
#[test]
fn pagination_is_stable_without_dup_or_miss() {
let (_root, core) = open_core("pagination");
let search = core.search();
for n in 1..=5u8 {
let id = reg(&core, n, "rik2");
// Different weights: title match for some, description for others.
let mut a = if n % 2 == 0 { ann(&format!("gadget {n}")) } else { ann(&format!("thing {n}")) };
if n % 2 != 0 {
a.description = format!("a gadget numbered {n}");
}
search.set_annotation(&id, &a, NOW).unwrap();
}
let mut query = q("gadget");
query.page_size = 2;
let mut seen = Vec::new();
let mut cursor: Option<Vec<u8>> = None;
let mut pages = 0;
loop {
let page = search.search(&query, &ANYONE, cursor.as_deref()).unwrap();
assert_eq!(page.total, 5, "total is constant on every page");
seen.extend(page.hits.iter().map(|h| h.asset_id));
pages += 1;
match page.cursor {
Some(c) => cursor = Some(c),
None => break,
}
assert!(pages < 10, "pagination must terminate");
}
assert_eq!(pages, 3, "5 hits at page_size 2 = 2+2+1");
assert_eq!(seen.len(), 5, "no dup, no miss");
let mut dedup = seen.clone();
dedup.sort();
dedup.dedup();
assert_eq!(dedup.len(), 5);
// Scores never increase across the walk.
let full = search.search(&q("gadget"), &ANYONE, None).unwrap();
assert_eq!(full.hits.iter().map(|h| h.asset_id).collect::<Vec<_>>(), seen);
// Browse mode (no terms) pages by asset_id with filters only.
let mut browse = q("");
browse.page_size = 2;
browse.filters = SearchFilters { namespace: Some("rik2"), ..Default::default() };
let mut seen = Vec::new();
let mut cursor: Option<Vec<u8>> = None;
loop {
let page = search.search(&browse, &ANYONE, cursor.as_deref()).unwrap();
assert_eq!(page.total, 5);
seen.extend(page.hits.iter().map(|h| h.asset_id));
match page.cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
assert_eq!(seen, (1..=5u8).map(asset_id_n).collect::<Vec<_>>());
}
#[test]
fn search_survives_restart() {
let root = test_root("search_restart");
let id;
{
let core = AssetServerCore::open(&root, Budgets::default_v1()).unwrap();
id = reg(&core, 1, "rik2");
let mut a = ann("Persistent Artifact");
a.tags = vec!["durable".into()];
core.search().set_annotation(&id, &a, NOW).unwrap();
}
let core = AssetServerCore::open(&root, Budgets::default_v1()).unwrap();
core.recover(NOW + 10).unwrap();
let page = core.search().search(&q("persistent"), &ANYONE, None).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.hits[0].asset_id, id);
let mut fq = q("");
fq.filters = SearchFilters { tag: Some("durable"), ..Default::default() };
assert_eq!(core.search().search(&fq, &ANYONE, None).unwrap().total, 1);
}
#[test]
fn mutation_reindexes_transactionally() {
let (_root, core) = open_core("reindex");
let search = core.search();
let id = reg(&core, 1, "rik2");
search.set_annotation(&id, &ann("obsidian golem"), NOW).unwrap();
assert_eq!(search.search(&q("obsidian"), &ANYONE, None).unwrap().total, 1);
// Replacing the annotation fully supersedes the old postings.
search.set_annotation(&id, &ann("marble statue"), NOW + 1).unwrap();
assert_eq!(search.search(&q("obsidian"), &ANYONE, None).unwrap().total, 0);
assert_eq!(search.search(&q("marble"), &ANYONE, None).unwrap().total, 1);
// Clearing removes the asset from hits and counts.
search.clear_annotation(&id).unwrap();
search.clear_annotation(&id).unwrap(); // idempotent
assert_eq!(search.search(&q("marble"), &ANYONE, None).unwrap().total, 0);
assert!(search.annotation(&id).unwrap().is_none());
}
#[test]
fn alias_head_mutation_updates_liveness() {
let (_root, core) = open_core("liveness");
let search = core.search();
let (id_a, rev_a) = publish_prop(&core, "rik2", 1, b"glb a", b"thumb a", NOW);
let (id_b, rev_b) = publish_prop(&core, "rik2", 2, b"glb b", b"thumb b", NOW);
search.set_annotation(&id_a, &ann("lantern alpha"), NOW).unwrap();
search.set_annotation(&id_b, &ann("lantern beta"), NOW).unwrap();
let live = |term: &str| {
let mut query = q(term);
query.filters = SearchFilters { live_only: true, ..Default::default() };
search.search(&query, &ANYONE, None).unwrap()
};
// No alias yet: nothing is live (hits and counts agree).
assert_eq!(live("lantern").total, 0);
let alias = "rik2/props/lamp".parse().unwrap();
core.catalog()
.set_asset_alias(&alias, &AssetRevisionRef { asset_id: id_a, revision: rev_a }, NOW + 1)
.unwrap();
let page = live("lantern");
assert_eq!((page.total, page.hits[0].asset_id, page.hits[0].live), (1, id_a, true));
// Retargeting the only alias to B flips both flags in one transaction.
core.catalog()
.set_asset_alias(&alias, &AssetRevisionRef { asset_id: id_b, revision: rev_b }, NOW + 2)
.unwrap();
let page = live("lantern");
assert_eq!((page.total, page.hits[0].asset_id), (1, id_b));
// Unfiltered search still sees both, with truthful live flags.
let page = search.search(&q("lantern"), &ANYONE, None).unwrap();
let flags: Vec<(AssetId, bool)> = page.hits.iter().map(|h| (h.asset_id, h.live)).collect();
assert_eq!(flags, vec![(id_a, false), (id_b, true)]);
// Quarantining the live head drops the alias and the liveness together.
core.catalog().quarantine_asset(&id_b, &rev_b, NOW + 3).unwrap();
assert_eq!(core.catalog().resolve_asset_alias(&alias).unwrap(), None);
assert_eq!(live("lantern").total, 0);
// Clearing an alias flips liveness in the same transaction too.
core.catalog()
.set_asset_alias(&alias, &AssetRevisionRef { asset_id: id_a, revision: rev_a }, NOW + 4)
.unwrap();
assert_eq!(live("lantern").total, 1);
assert!(core.catalog().clear_asset_alias(&alias).unwrap());
assert!(!core.catalog().clear_asset_alias(&alias).unwrap(), "second clear is a no-op");
assert_eq!(live("lantern").total, 0);
// The annotations themselves survive every head mutation.
assert_eq!(search.search(&q("lantern"), &ANYONE, None).unwrap().total, 2);
}
#[test]
fn authorization_scope_constrains_hits_snippets_and_counts() {
let (_root, core) = open_core("authz");
let search = core.search();
let owner = pid_n(9);
let stranger = pid_n(8);
let as_owner = SearchViewer { principal: Some(owner), scope: ViewerScope::All };
let as_stranger = SearchViewer { principal: Some(stranger), scope: ViewerScope::All };
// Private annotation: invisible to anonymous and other principals in
// hits AND totals; visible to its owner.
let secret = reg(&core, 1, "rik2");
let mut a = ann("hidden reactor");
a.visibility = Visibility::Private;
a.owner = Some(owner);
search.set_annotation(&secret, &a, NOW).unwrap();
assert_eq!(search.search(&q("reactor"), &ANYONE, None).unwrap().total, 0);
assert_eq!(search.search(&q("reactor"), &as_stranger, None).unwrap().total, 0);
let page = search.search(&q("reactor"), &as_owner, None).unwrap();
assert_eq!((page.total, page.hits.len()), (1, 1));
// Prompt terms on a PUBLIC annotation match only for the owner: for
// everyone else the prompt contributes zero to hits, ranking and counts.
let pub_id = reg(&core, 2, "rik2");
let mut a = ann("ordinary vase");
a.owner = Some(owner);
a.prompt = "xylograph ceremonial urn in moonlight".into();
a.description = "a simple container".into();
search.set_annotation(&pub_id, &a, NOW).unwrap();
assert_eq!(search.search(&q("xylograph"), &ANYONE, None).unwrap().total, 0);
assert_eq!(search.search(&q("xylograph"), &as_stranger, None).unwrap().total, 0);
let page = search.search(&q("xylograph"), &as_owner, None).unwrap();
assert_eq!(page.total, 1);
// Even the owner's snippet is built from title/description only.
assert!(!page.hits[0].snippet.contains("xylograph"), "{}", page.hits[0].snippet);
assert!(!page.hits[0].snippet.contains("moonlight"));
// The public fields still match for everyone.
assert_eq!(search.search(&q("vase"), &ANYONE, None).unwrap().total, 1);
// Namespace scoping constrains hits and counts.
let foreign = reg(&core, 3, "other");
search.set_annotation(&foreign, &ann("ordinary crate"), NOW).unwrap();
let ns = ["rik2"];
let scoped = SearchViewer { principal: None, scope: ViewerScope::Namespaces(&ns) };
assert_eq!(search.search(&q("ordinary"), &ANYONE, None).unwrap().total, 2);
let page = search.search(&q("ordinary"), &scoped, None).unwrap();
assert_eq!((page.total, page.hits.len()), (1, 1));
assert_eq!(page.hits[0].asset_id, pub_id);
// Zero allowed namespaces sees nothing at all.
let nothing = SearchViewer { principal: None, scope: ViewerScope::Namespaces(&[]) };
let page = search.search(&q("ordinary"), &nothing, None).unwrap();
assert_eq!((page.total, page.hits.len()), (0, 0));
// Owner filter never bypasses visibility: a stranger filtering by the
// owner's principal still cannot see the private annotation.
let mut query = q("reactor");
query.filters = SearchFilters { owner: Some(owner), ..Default::default() };
assert_eq!(search.search(&query, &as_stranger, None).unwrap().total, 0);
assert_eq!(search.search(&query, &as_owner, None).unwrap().total, 1);
}
#[test]
fn kind_is_typed_filterable_and_searchable() {
let (_root, core) = open_core("kind");
let search = core.search();
let a = reg(&core, 1, "rik2");
let b = reg(&core, 2, "rik2");
let c = reg(&core, 3, "rik2");
let mut aa = ann("stone bench");
aa.kind = Some(AssetKind::Prop);
aa.tags = vec!["garden".into()];
search.set_annotation(&a, &aa, NOW).unwrap();
let mut ba = ann("farm cart");
ba.kind = Some(AssetKind::Vehicle);
search.set_annotation(&b, &ba, NOW).unwrap();
// c declares no kind at all.
search.set_annotation(&c, &ann("mystery item"), NOW).unwrap();
// Exact browse filter: only the matching kind; unspecified rows match no
// kind filter, ever.
let by_kind = |kind| {
let mut query = q("");
query.filters = SearchFilters { kind: Some(kind), ..Default::default() };
let page = search.search(&query, &ANYONE, None).unwrap();
(page.hits.iter().map(|h| h.asset_id).collect::<Vec<_>>(), page.total)
};
assert_eq!(by_kind(AssetKind::Prop), (vec![a], 1));
assert_eq!(by_kind(AssetKind::Vehicle), (vec![b], 1));
assert_eq!(by_kind(AssetKind::Audio), (vec![], 0));
// The kind name is also a public lexical term.
let page = search.search(&q("vehicle"), &ANYONE, None).unwrap();
assert_eq!((page.total, page.hits[0].asset_id), (1, b));
// Hits carry the typed kind; unspecified rows carry None.
assert_eq!(page.hits[0].kind, Some(AssetKind::Vehicle));
let page = search.search(&q("mystery"), &ANYONE, None).unwrap();
assert_eq!(page.hits[0].kind, None);
// Full-fidelity read round-trips the kind.
assert_eq!(search.annotation(&a).unwrap().unwrap().kind, Some(AssetKind::Prop));
assert_eq!(search.annotation(&c).unwrap().unwrap().kind, None);
// Kind composes with the other exact filters.
let mut query = q("");
query.filters =
SearchFilters { kind: Some(AssetKind::Prop), tag: Some("garden"), ..Default::default() };
assert_eq!(search.search(&query, &ANYONE, None).unwrap().total, 1);
query.filters =
SearchFilters { kind: Some(AssetKind::Vehicle), tag: Some("garden"), ..Default::default() };
assert_eq!(search.search(&query, &ANYONE, None).unwrap().total, 0);
// The kind filter is part of the cursor fingerprint: a cursor minted
// under it is stale against the same text without it.
let d = reg(&core, 4, "rik2");
let mut da = ann("stone table");
da.kind = Some(AssetKind::Prop);
search.set_annotation(&d, &da, NOW).unwrap();
let mut paged = q("stone");
paged.page_size = 1;
paged.filters = SearchFilters { kind: Some(AssetKind::Prop), ..Default::default() };
let cursor = search.search(&paged, &ANYONE, None).unwrap().cursor.expect("two prop hits");
let mut unfiltered = q("stone");
unfiltered.page_size = 1;
assert!(matches!(
search.search(&unfiltered, &ANYONE, Some(&cursor)).unwrap_err(),
ServerError::InvalidInput { what: "stale search cursor" }
));
// Against the identical shape the cursor continues.
let page2 = search.search(&paged, &ANYONE, Some(&cursor)).unwrap();
assert_eq!((page2.hits.len(), page2.hits[0].asset_id), (1, d));
}
#[test]
fn search_index_terms_budget_is_enforced_before_any_write() {
let root = test_root("index_terms_budget");
let budgets = Budgets { max_search_index_terms: 8, ..Budgets::default_v1() };
let core = AssetServerCore::open(&root, budgets).unwrap();
let search = core.search();
let id = reg(&core, 1, "rik2");
search.set_annotation(&id, &ann("alpha beta"), NOW).unwrap();
// Nine distinct terms against a budget of eight refuses…
let err = search
.set_annotation(&id, &ann("one two three four five six seven eight nine"), NOW + 1)
.unwrap_err();
assert!(
matches!(err, ServerError::OverBudget { what: "search index terms", limit: 8, found: 9 }),
"{err}"
);
// …and the previous annotation is fully intact: the refusal happened
// before any index row was touched.
assert_eq!(search.annotation(&id).unwrap().unwrap().title, "alpha beta");
assert_eq!(search.search(&q("alpha"), &ANYONE, None).unwrap().total, 1);
assert_eq!(search.search(&q("one"), &ANYONE, None).unwrap().total, 0);
// Exactly at the budget is admitted.
search.set_annotation(&id, &ann("one two three four five six seven eight"), NOW + 2).unwrap();
assert_eq!(search.search(&q("eight"), &ANYONE, None).unwrap().total, 1);
}
#[test]
fn hostile_text_stays_data_never_sql_or_wildcards() {
let (_root, core) = open_core("hostile");
let search = core.search();
let id = reg(&core, 1, "rik2");
let mut a = ann("Bob's \"quoted\" 100% _thing_ \\ backslash");
a.creator = "50%_done'; DROP TABLE search_annotations; --".into();
search.set_annotation(&id, &a, NOW).unwrap();
// Injection-shaped query text is nothing but terms — and those terms FIND
// the annotation whose creator contains the same words, proving the
// hostile string went in as data, not as SQL.
let page = search.search(&q("'; DROP TABLE search_annotations; --"), &ANYONE, None).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.hits[0].asset_id, id);
// Filters are equality over bound literals: no quoting escape, no LIKE
// wildcard semantics for % or _.
let mut fq = q("");
fq.filters = SearchFilters {
creator: Some("50%_done'; DROP TABLE search_annotations; --"),
..Default::default()
};
assert_eq!(search.search(&fq, &ANYONE, None).unwrap().total, 1, "exact literal matches");
fq.filters = SearchFilters { creator: Some("50%"), ..Default::default() };
assert_eq!(search.search(&fq, &ANYONE, None).unwrap().total, 0, "% is not a wildcard");
fq.filters = SearchFilters { creator: Some("50%_done"), ..Default::default() };
assert_eq!(search.search(&fq, &ANYONE, None).unwrap().total, 0, "_ is not a wildcard");
fq.filters = SearchFilters { creator: Some("x' OR '1'='1"), ..Default::default() };
assert_eq!(search.search(&fq, &ANYONE, None).unwrap().total, 0, "quotes stay data");
// Everything still works after the hostile traffic: the tables survived.
let id2 = reg(&core, 2, "rik2");
search.set_annotation(&id2, &ann("still standing"), NOW + 1).unwrap();
assert_eq!(search.search(&q("standing"), &ANYONE, None).unwrap().total, 1);
// Overlong words truncate identically at index and query time.
let long = "q".repeat(40);
let id3 = reg(&core, 3, "rik2");
search.set_annotation(&id3, &ann(&format!("prefix {long} suffix")), NOW + 2).unwrap();
let page = search.search(&q(&long), &ANYONE, None).unwrap();
assert_eq!((page.total, page.hits[0].asset_id), (1, id3));
}
#[test]
fn unicode_survives_admission_snippets_and_search() {
let (_root, core) = open_core("unicode");
let search = core.search();
let id = reg(&core, 1, "rik2");
let mut a = ann("Übersicht 現在図 sparkle");
// Multibyte padding forces the snippet window to cut inside non-ASCII
// text on both sides of the matched term.
a.description = format!("{} needle {}", "über現在ø ".repeat(40), "śpäß例 ".repeat(40));
search.set_annotation(&id, &a, NOW).unwrap();
let page = search.search(&q("needle"), &ANYONE, None).unwrap();
let snippet = &page.hits[0].snippet;
assert!(snippet.contains("needle"), "{snippet}");
assert!(snippet.len() <= 320, "byte-bounded at char boundaries: {}", snippet.len());
// Terms are ASCII runs: the ASCII tail of a mixed word is indexed, and
// querying it finds the annotation.
assert_eq!(search.search(&q("bersicht"), &ANYONE, None).unwrap().total, 1);
}
#[test]
fn snippets_are_normalized_bounded_and_term_centered() {
let (_root, core) = open_core("snippets");
let search = core.search();
let id = reg(&core, 1, "rik2");
// Escape bytes are refused at admission — they can never even reach a
// snippet. Legal whitespace (\n \t \r) is admitted and normalized out.
let mut a = ann("Console Log");
a.description = "sneaky \x1b[31mred\x1b[0m text".into();
assert!(matches!(
search.set_annotation(&id, &a, NOW).unwrap_err(),
ServerError::InvalidInput { what: "annotation description charset" }
));
let mut a = ann("Console Log");
a.description = format!(
"start red\ttext\nwith gaps {} needle here and a tail",
"padding word ".repeat(40)
);
search.set_annotation(&id, &a, NOW).unwrap();
let page = search.search(&q("needle"), &ANYONE, None).unwrap();
let snippet = &page.hits[0].snippet;
assert!(snippet.contains("needle"), "{snippet}");
assert!(snippet.len() <= 320, "snippet bounded: {}", snippet.len());
assert!(!snippet.chars().any(|c| c.is_control()), "control bytes stripped");
assert!(!snippet.contains(" "), "whitespace collapsed");
// A hit matched via title has a snippet from the description head.
let page = search.search(&q("console"), &ANYONE, None).unwrap();
assert!(page.hits[0].snippet.starts_with("start red text"), "{}", page.hits[0].snippet);
}
/// Facets describe the WHOLE result set, not the page, and they obey every
/// filter that shaped it — including the negative one. They ride the page so
/// the counts and the rows come out of one snapshot.
#[test]
fn facets_count_the_whole_result_set_under_the_same_filters() {
let (_root, core) = open_core("facets");
let search = core.search();
let labelled = |title: &str, categories: &[&str], tags: &[&str]| {
let mut x = ann(title);
x.categories = categories.iter().map(|c| (*c).to_string()).collect();
x.tags = tags.iter().map(|t| (*t).to_string()).collect();
x
};
// Four robots: three doom props, one kenney prop, one of them also
// intermediate (the label the pads exclude).
let ids: Vec<AssetId> = (1..=4).map(|n| reg(&core, n, "rik2")).collect();
search
.set_annotation(&ids[0], &labelled("alpha robot", &["doom"], &["prop"]), NOW)
.unwrap();
search
.set_annotation(&ids[1], &labelled("beta robot", &["doom"], &["prop"]), NOW)
.unwrap();
search
.set_annotation(
&ids[2],
&labelled("gamma robot", &["doom"], &["prop", "intermediate"]),
NOW,
)
.unwrap();
search
.set_annotation(&ids[3], &labelled("delta robot", &["kenney"], &["prop"]), NOW)
.unwrap();
let facets = |filters: SearchFilters, page_size: u32| {
let mut query = q("robot");
query.filters = filters;
query.page_size = page_size;
query.facets = 16;
let page = search.search(&query, &ANYONE, None).unwrap();
let counts: Vec<(String, String, u64)> = page
.facets
.iter()
.map(|f| (f.kind.as_str().to_string(), f.label.clone(), f.count))
.collect();
(page.hits.len(), page.total, counts)
};
// One hit per page, but the facets still count all four assets.
let (shown, total, counts) = facets(SearchFilters::default(), 1);
assert_eq!((shown, total), (1, 4));
assert_eq!(
counts,
vec![
("tag".to_string(), "prop".to_string(), 4),
("category".to_string(), "doom".to_string(), 3),
("category".to_string(), "kenney".to_string(), 1),
("tag".to_string(), "intermediate".to_string(), 1),
],
"most used first, then kind and label"
);
// A filter narrows the facets with the rows: excluding `intermediate`
// drops it from the counts entirely and takes doom down with it.
let (_, total, counts) = facets(
SearchFilters { exclude_tag: Some("intermediate"), ..Default::default() },
10,
);
assert_eq!(total, 3);
assert_eq!(
counts,
vec![
("tag".to_string(), "prop".to_string(), 3),
("category".to_string(), "doom".to_string(), 2),
("category".to_string(), "kenney".to_string(), 1),
]
);
// Picking a facet is just another filter; the remaining labels are the
// ones that still co-occur with it.
let (_, total, counts) =
facets(SearchFilters { category: Some("kenney"), ..Default::default() }, 10);
assert_eq!(total, 1);
assert_eq!(
counts,
vec![
("category".to_string(), "kenney".to_string(), 1),
("tag".to_string(), "prop".to_string(), 1),
]
);
// Not asking costs nothing and returns nothing.
let mut plain = q("robot");
plain.page_size = 10;
assert!(search.search(&plain, &ANYONE, None).unwrap().facets.is_empty());
// Over the budget refuses rather than silently truncating.
let mut greedy = q("robot");
greedy.facets = 100_000;
assert!(matches!(
search.search(&greedy, &ANYONE, None).unwrap_err(),
ServerError::OverBudget { what: "search facets", .. }
));
}