Skip to main content

cloudillo_search/
handler.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! `GET /api/search` — the single full-text query surface.
5//!
6//! # Authorization
7//!
8//! The endpoint is reachable **unauthenticated** — it sits under `optional_auth`
9//! in `routes/public.rs`, and a tokenless caller is treated as the `"guest"`
10//! subject, which the derivation below turns into [`SubjectAccessLevel::Public`],
11//! the level `GET /api/files` gives the same caller. Three layers, in order:
12//!
13//! 1. **Scoped tokens.** A share-link holder carries `file:{id}:{R|C|W}`. Only
14//!    that variant is accepted — an unparseable or `apkg:publish` scope is a
15//!    hard 403, never a widening. The token confines the query to its own
16//!    document tree and to file/document rows: a link recipient has no business
17//!    searching the tenant's posts or profiles.
18//!
19//!    A scope narrows the *subtree*; the visibility derivation below still runs
20//!    for every caller and the scope applies on top of it. A share link to a
21//!    folder is not a grant over that folder's Direct and Connected children, and
22//!    `GET /api/files` does not treat it as one either. The one exception is
23//!    [`SearchOptions::scope_grant_file_id`]: the shared file's own row and the
24//!    deep `'D'` parts of its tree bypass the level filter, because the share
25//!    *is* permission to read that document — without it a link to a private
26//!    note would search to zero hits inside a note its holder can open. Child
27//!    `'F'` rows in the tree keep the level filter.
28//!
29//!    A scoped token is never the owner, whatever its subject says: share-link
30//!    tokens are minted with `sub: None` and `iss` = the tenant, and validation
31//!    resolves `id_tag` as `sub.unwrap_or(iss)`. See [`subject_level`].
32//! 2. **SQL prefilter.** Per object type, the same rule the corresponding list
33//!    endpoint applies, so pagination counts only rows the caller could see:
34//!
35//!    - `'P'` profiles are **not** filtered — tenant-scoped, and any
36//!      authenticated caller in the tenant may find them, exactly as
37//!      `GET /api/profiles` allows. That does not extend to an anonymous caller,
38//!      so an unauthenticated request has `'P'` dropped from its `obj_tp` filter
39//!      outright (see [`guest_obj_tp`]); otherwise anyone could enumerate the
40//!      tenant's whole contact graph, cached remote profiles included.
41//!    - `'F'` files and `'D'` deep parts are filtered by `visible_levels`,
42//!      derived from the caller's relationship to the tenant precisely as
43//!      `GET /api/files` derives it — the tenant owns both.
44//!    - `'A'` actions use the canonical predicate shared with `GET /api/actions`,
45//!      keyed on the **issuer**, not the tenant: following the tenant does not
46//!      make the caller a follower of every issuer whose posts it federated in.
47//!
48//!    An owner — caller `id_tag` == tenant `id_tag` *and* no scope — gets
49//!    `visible_levels = None`, and then no predicate is emitted at all.
50//! 3. **Redundant post-check.** The SQL prefilter above *is* the authorization.
51//!    [`file_access::check_scope_allows_file`] is exactly
52//!    `file_id == scope || root_id == scope`, the same predicate the adapter
53//!    already pushed down for `scope_file_id`, so under a correct prefilter it
54//!    never drops a row. Kept as a cross-check against future drift in either
55//!    half, and loud: a non-zero drop count logs at `warn!`.
56//!
57//! # Pagination
58//!
59//! Results are relevance-ordered, so this endpoint uses `limit`/`offset` rather
60//! than the keyset cursor the rest of the API uses — a cursor over a `bm25()`
61//! ordering has nothing stable to anchor on. `offset` is capped.
62//!
63//! `pagination.total` is the only has-more signal the response carries: derived
64//! from the page alone it would equal `offset + len` and every page would look
65//! like the last one. It normally comes from a second adapter call,
66//! [`cloudillo_types::meta_adapter::MetaAdapter::count_search`], over the same SQL
67//! filters as the page itself.
68//!
69//! That second call is **skipped when the page answers the question by itself**:
70//! a first page (`offset == 0`) shorter than `limit` is the whole match set, so
71//! its length *is* the total. This is the common case on a route reachable with
72//! no token at all, where `?q=a` — a `"a"*` prefix matching most of the corpus —
73//! would otherwise cost two full ranked FTS scans, each re-running the per-row
74//! correlated `EXISTS` over `actions`. The decision reads the raw adapter row
75//! count, before the post-check, so it stays consistent with what the SQL
76//! matched. Not `tokio::join!`ed with the page: that would undo the skip.
77//!
78//! When it does run, the count **saturates** at
79//! `SEARCH_MAX_OFFSET + SEARCH_MAX_LIMIT`. `offset` is clamped to
80//! `SEARCH_MAX_OFFSET`, so no page a caller can reach lies past it and the
81//! has-more signal stays exact where a caller can act on it. Uncapped, such a
82//! request would walk the tenant's whole match set.
83
84use std::collections::HashMap;
85
86use axum::{
87	Json,
88	extract::{Query, State},
89	http::StatusCode,
90};
91use cloudillo_core::{
92	abac::{SubjectAccessLevel, relationship_level},
93	extract::{IdTag, OptionalAuth, OptionalRequestId},
94	file_access::{self, ScopeCheck},
95};
96use cloudillo_types::{
97	auth_adapter::AuthCtx,
98	meta_adapter::{
99		SEARCH_MAX_CONTENT_TYPES, SEARCH_MAX_LIMIT, SEARCH_MAX_OFFSET, SEARCH_MAX_TAGS,
100		SearchMatch, SearchOptions, SearchRow,
101	},
102	types::{ApiResponse, TokenScope, serialize_timestamp_iso},
103};
104use serde::{Deserialize, Serialize};
105
106use crate::{
107	indexer::{OBJ_DOC, OBJ_FILE},
108	objects::{OBJ_ACTION, OBJ_PROFILE},
109	prelude::*,
110};
111
112const DEFAULT_LIMIT: u32 = 20;
113
114/// Longest accepted `q`. Every token becomes a quoted term plus an ` AND ` in
115/// the FTS5 MATCH expression, so an unbounded query is an unbounded expression;
116/// `limit` and `offset` are capped and this is the third knob.
117const MAX_QUERY_CHARS: usize = 256;
118
119/// Longest accepted single `tags` / `contentType` entry. A tag is a word and a
120/// content type is a short MIME string; the cap only bounds a pathological one.
121const MAX_FILTER_ENTRY_CHARS: usize = 128;
122
123/// Query parameters for `GET /api/search`.
124///
125/// Deliberately **not** `deny_unknown_fields`, matching every other query struct in
126/// the API. This route is reachable with no token at all, and a parameter this build
127/// has never heard of must degrade to "ignored" rather than to a 400 on the whole
128/// request — the same stance [`parse_types`] takes for an unknown `?type=` name.
129#[derive(Debug, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct SearchQuery {
132	pub q: String,
133	/// Comma-separated subset of `file,doc,action,profile`.
134	pub r#type: Option<String>,
135	/// Restrict to one container document and its parts.
136	pub file_id: Option<String>,
137	/// Comma-separated content types.
138	pub content_type: Option<String>,
139	/// Comma-separated tags, AND-combined. Applied inside the FTS match, so a
140	/// text+tag query cannot lose a hit to the relevance cut.
141	///
142	/// With `tags` present, `q` may be empty — that is a tag-only browse.
143	pub tags: Option<String>,
144	pub limit: Option<u32>,
145	pub offset: Option<u32>,
146}
147
148/// One result row on the wire.
149///
150/// Absent fields are omitted rather than sent as `null`: the frontend types
151/// these as optional (`T.optional` in `libs/types/src/types.ts`), which in
152/// `@symbion/runtype` means "may be missing", not "may be null". A hit is
153/// mostly-empty for whole-object rows, so this also keeps the payload small.
154#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SearchHit {
157	/// `'F'` file, `'D'` deep document part, `'A'` action, `'P'` profile.
158	pub obj_tp: char,
159	pub obj_id: Box<str>,
160	/// Deep-link key — for notillo, the page id.
161	#[serde(skip_serializing_if = "Option::is_none")]
162	pub part_id: Option<Box<str>>,
163	#[serde(skip_serializing_if = "Option::is_none")]
164	pub part_kind: Option<Box<str>>,
165	#[serde(skip_serializing_if = "Option::is_none")]
166	pub parent_part: Option<Box<str>>,
167	/// Finest-grained anchor inside the part — for notillo, the block id.
168	#[serde(skip_serializing_if = "Option::is_none")]
169	pub anchor_id: Option<Box<str>>,
170	/// App id parsed out of `cloudillo/<appId>`, for building a `cl:` ref.
171	#[serde(skip_serializing_if = "Option::is_none")]
172	pub app_id: Option<Box<str>>,
173	/// Deep-link query param name from the format manifest, e.g. `"nav"`.
174	#[serde(skip_serializing_if = "Option::is_none")]
175	pub nav_param: Option<Box<str>>,
176	#[serde(skip_serializing_if = "Option::is_none")]
177	pub content_type: Option<Box<str>>,
178	#[serde(skip_serializing_if = "Option::is_none")]
179	pub title: Option<Box<str>>,
180	/// Server-built excerpt as plain text; `snippetMatches` carries the highlight
181	/// out of band. Contains no markup and must not be fed to an HTML sink.
182	///
183	/// **Absent for every hit** when the tenant has `search.store_text` off:
184	/// that mode keeps no plain-text copy to cut an excerpt out of. A result list
185	/// must fall back to title + tags rather than assume a snippet is there.
186	#[serde(skip_serializing_if = "Option::is_none")]
187	pub snippet: Option<Box<str>>,
188	/// Ranges within `snippet` to emphasise. **UTF-16 code-unit** offsets, so a
189	/// client can slice `snippet` directly; ascending and non-overlapping.
190	#[serde(skip_serializing_if = "Option::is_none")]
191	pub snippet_matches: Option<Box<[SearchMatch]>>,
192	#[serde(skip_serializing_if = "Option::is_none")]
193	pub tags: Option<Box<[Box<str>]>>,
194	#[serde(skip_serializing_if = "Option::is_none")]
195	pub owner_tag: Option<Box<str>>,
196	#[serde(serialize_with = "serialize_timestamp_iso")]
197	pub updated_at: Timestamp,
198	/// Sign-flipped `bm25()`: higher is more relevant.
199	pub score: f64,
200}
201
202pub async fn get_search(
203	State(app): State<App>,
204	tn_id: TnId,
205	IdTag(tenant_id_tag): IdTag,
206	OptionalAuth(maybe_auth): OptionalAuth,
207	OptionalRequestId(req_id): OptionalRequestId,
208	Query(q): Query<SearchQuery>,
209) -> ClResult<(StatusCode, Json<ApiResponse<Vec<SearchHit>>>)> {
210	let limit = q.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, SEARCH_MAX_LIMIT);
211	let offset = q.offset.unwrap_or(0).min(SEARCH_MAX_OFFSET);
212	let authenticated = maybe_auth.is_some();
213	// The same synthesis `check_file_permission` uses for unauthenticated file
214	// reads, so the `"guest"` subject the visibility derivation below tests for is
215	// the one it actually sees.
216	let auth = maybe_auth.unwrap_or_else(|| AuthCtx {
217		tn_id,
218		id_tag: "guest".into(),
219		roles: vec![].into(),
220		scope: None,
221		// A guest has no identity to assert, matching the other synthesised guest
222		// contexts. Inert here: visibility below derives from `scope` + `id_tag`, never
223		// from this flag, which must not be used for authorization.
224		anonymous: true,
225	});
226	if q.q.chars().count() > MAX_QUERY_CHARS {
227		return Err(Error::ValidationError("Search query too long".into()));
228	}
229	let content_type =
230		csv_filter(q.content_type.as_deref(), SEARCH_MAX_CONTENT_TYPES, "contentType")?;
231	let tags = csv_filter(q.tags.as_deref(), SEARCH_MAX_TAGS, "tags")?;
232
233	let mut opts = SearchOptions {
234		q: q.q,
235		obj_tp: obj_tp_filter(q.r#type.as_deref()),
236		file_id: q.file_id,
237		content_type,
238		tags,
239		limit,
240		offset,
241		// Read from the same setting the write path uses, so a query always hits
242		// the table this tenant's rows are actually in. Hits from the contentless
243		// one carry no `snippet`, and the result list falls back to title + tags.
244		fts_cl: !crate::store_text(&app, tn_id).await,
245		..Default::default()
246	};
247
248	// Same derivation as `GET /api/files`, and unconditional: a scope *narrows*
249	// the result set on top of this, it never replaces it. Leaving
250	// `visible_levels` at `None` for a scoped caller would hand a share-link guest
251	// the tenant-owner level over the whole shared document tree.
252	let subject = auth.id_tag.as_ref();
253	let rels = app.meta_adapter.get_relationships(tn_id, &[subject]).await?;
254	let (following, connected) = rels.get(subject).copied().unwrap_or((false, false));
255	let level =
256		subject_level(auth.scope.as_deref(), subject, tenant_id_tag.as_ref(), connected, following);
257	opts.visible_levels = level.visible_levels().map(<[char]>::to_vec);
258	// Same test `subject_level` uses, so the two can never disagree: a share-link
259	// guest resolves to the tenant's own id_tag, and handing that to the adapter
260	// as the viewer would match every row the tenant owns. `None` is an
261	// unidentified viewer.
262	opts.viewer_id_tag =
263		(!is_anonymous_share(auth.scope.as_deref(), subject, tenant_id_tag.as_ref()))
264			.then(|| subject.to_owned());
265
266	// Applied before the scope narrowing below, so a file scope still narrows on
267	// top of it.
268	if !authenticated {
269		opts.obj_tp = Some(guest_obj_tp(opts.obj_tp.take()));
270	}
271
272	if let Some(scope) = auth.scope.as_deref() {
273		// Only a file scope reaches here; anything else — including a scope
274		// string this build cannot parse — is denied rather than ignored.
275		let Some(TokenScope::File { file_id, .. }) = TokenScope::parse(scope) else {
276			return Err(Error::PermissionDenied);
277		};
278		opts.scope_file_id = Some(file_id.clone());
279		// The share itself is the grant: the shared file's own row and the deep
280		// parts of its document tree stay visible even at Direct visibility, or a
281		// share link to a private document would search to zero hits inside a
282		// document its holder can open. Child `'F'` rows in the tree keep the level
283		// filter — same split `GET /api/files` makes.
284		opts.scope_grant_file_id = Some(file_id.clone().into());
285		opts.obj_tp = Some(scope_obj_tp(opts.obj_tp.take()));
286	}
287
288	// Redundant cross-check, not a second gate — see the module docs.
289	let scope = auth.scope.as_deref();
290	let fetched = app.meta_adapter.search(tn_id, &opts).await?;
291	let fetched_len = fetched.len();
292
293	// Decided off `fetched_len`, the raw adapter row count, not the post-checked
294	// `rows.len()` below, so it stays consistent with what the SQL matched.
295	let total = match total_from_page(offset, fetched_len, limit) {
296		Some(total) => total,
297		// Taken over the same SQL filters as the page, so it agrees with it
298		// exactly — see the module docs on the one case that makes it an upper
299		// bound.
300		None => app.meta_adapter.count_search(tn_id, &opts).await?,
301	};
302
303	let rows: Vec<SearchRow> = fetched
304		.into_iter()
305		.filter(|row| {
306			!matches!(row.obj_tp, OBJ_FILE | OBJ_DOC)
307				|| !matches!(
308					file_access::check_scope_allows_file(
309						scope,
310						&row.obj_id,
311						row.root_id.as_deref()
312					),
313					ScopeCheck::Denied
314				)
315		})
316		.collect();
317	if rows.len() < fetched_len {
318		warn!(
319			tn_id = %tn_id,
320			dropped = fetched_len - rows.len(),
321			"Search scope post-check dropped rows the SQL prefilter admitted"
322		);
323	}
324
325	let nav_params = read_nav_params(&app, tn_id, &rows).await;
326	let hits: Vec<SearchHit> = rows.into_iter().map(|row| to_hit(row, &nav_params)).collect();
327
328	let total = usize::try_from(total).unwrap_or(0);
329	let response = ApiResponse::with_pagination(hits, offset as usize, limit as usize, total)
330		.with_req_id(req_id.unwrap_or_default());
331	Ok((StatusCode::OK, Json(response)))
332}
333
334/// `pagination.total` when the page alone settles it, `None` when the second FTS
335/// scan has to run.
336///
337/// A *first* page shorter than `limit` is the whole match set, so its length is
338/// the answer. Anywhere else the page says nothing about what lies past it.
339///
340/// `len` is the raw adapter row count, before the scope post-check, so the
341/// decision matches what the SQL matched rather than what survived a cross-check
342/// that never fires under a correct prefilter.
343fn total_from_page(offset: u32, len: usize, limit: u32) -> Option<i64> {
344	(offset == 0 && len < usize::try_from(limit).unwrap_or(usize::MAX))
345		.then(|| i64::try_from(len).unwrap_or(i64::MAX))
346}
347
348/// Is this caller a scoped token holder who identifies nobody the handler can
349/// tell apart from the tenant?
350///
351/// A share-link token is minted with `sub: None`, so token validation resolves
352/// its `id_tag` to `iss` — the tenant itself. Everything that would otherwise
353/// hand such a caller the tenant's own privileges must test for it the same way:
354/// [`subject_level`] for the visibility level, and the `viewer_id_tag` assignment
355/// in [`get_search`]. A viewer tag makes the caller *identified* to the adapter —
356/// admitting every `'P'` profile row and feeding the issuer-keyed action
357/// predicate — so passing the tenant's own tag would hand an anonymous link
358/// holder the tenant's contact graph. What such a caller may see comes from
359/// [`SearchOptions::scope_grant_file_id`] alone.
360fn is_anonymous_share(scope: Option<&str>, subject: &str, tenant_id_tag: &str) -> bool {
361	scope.is_some() && subject == tenant_id_tag
362}
363
364/// Which visibility levels a caller may see.
365///
366/// Pure, so the rule is testable without an `App`, and because getting it wrong
367/// is a disclosure bug rather than a wrong result.
368///
369/// The first branch is the load-bearing one. A share-link token is minted with
370/// `sub: None` (`cloudillo-auth`'s share-token handler) and `iss` = the tenant,
371/// and validation resolves `id_tag` as `claims.sub.unwrap_or(claims.iss)` — so
372/// `auth.id_tag` for a share-link guest *is* the tenant's own id_tag. Testing the
373/// subject alone would derive `Owner` for every such guest and skip the
374/// visibility block entirely. Such a token identifies nobody, so it is treated as
375/// the anonymous caller it is; what it may see beyond the public level comes from
376/// [`SearchOptions::scope_grant_file_id`] alone.
377///
378/// **Any** scoped token is demoted this way, including the tenant owner's own —
379/// minted with `sub: Some(&auth.id_tag)`, which resolves to the same `id_tag` as
380/// `iss`, and `AuthCtx` keeps no `sub` to tell the two apart. That is the
381/// intended sandbox: a file scope is handed to a potentially untrusted app, so it
382/// grants its own scope plus public content and no ambient authority from
383/// whoever's session minted it. The observable consequence — not a bug — is that
384/// the tenant owner searching from inside their own app iframe sees only
385/// `visibility='P'` rows outside the `scope_grant_file_id` exemption.
386fn subject_level(
387	scope: Option<&str>,
388	subject: &str,
389	tenant_id_tag: &str,
390	connected: bool,
391	following: bool,
392) -> SubjectAccessLevel {
393	if is_anonymous_share(scope, subject, tenant_id_tag) {
394		return SubjectAccessLevel::Public;
395	}
396	let is_real_auth = !subject.is_empty() && subject != "guest";
397	relationship_level(subject == tenant_id_tag, connected, following, is_real_auth)
398}
399
400/// Look up the deep-link query param of every content type on this page.
401///
402/// One `doc_format::resolve` per *distinct* content type, not one per hit: the value
403/// is identical across every row sharing a type, and a page rarely spans more
404/// than one or two. Awaited inside the result loop it would be up to `limit`
405/// serialized round-trips for the same answer.
406async fn read_nav_params(
407	app: &App,
408	tn_id: TnId,
409	rows: &[SearchRow],
410) -> HashMap<Box<str>, Option<Box<str>>> {
411	let mut out: HashMap<Box<str>, Option<Box<str>>> = HashMap::new();
412	// Only deep rows need it — a whole-file hit has no part to navigate to.
413	for row in rows.iter().filter(|r| !r.part_id.is_empty()) {
414		let Some(ct) = row.content_type.as_deref() else { continue };
415		if out.contains_key(ct) {
416			continue;
417		}
418		let nav_param = cloudillo_core::doc_format::resolve(app, tn_id, ct)
419			.await
420			.inspect_err(|e| {
421				warn!(content_type = ct, error = %e, "Cannot read doc format for nav param");
422			})
423			.ok()
424			.flatten()
425			.and_then(|f| f.nav_param);
426		out.insert(ct.into(), nav_param);
427	}
428	out
429}
430
431/// Build the client-facing hit, enriching it with the deep-link metadata the
432/// client needs to construct `cl:{appId}/{owner}:{fileId}?{navParam}={partId}`.
433///
434/// `nav_params` is [`read_nav_params`]' per-content-type lookup, so this is a
435/// plain synchronous mapping with no database access of its own.
436fn to_hit(row: SearchRow, nav_params: &HashMap<Box<str>, Option<Box<str>>>) -> SearchHit {
437	let content_type = row.content_type;
438	let nav_param = match (&content_type, row.part_id.is_empty()) {
439		(Some(ct), false) => nav_params.get(ct.as_ref()).cloned().flatten(),
440		_ => None,
441	};
442
443	// The stored ids are namespaced by part kind; the client deep-links with the
444	// app's own document id.
445	let kind = row.part_kind.as_deref();
446	let part_id: Option<Box<str>> =
447		(!row.part_id.is_empty()).then(|| strip_kind(&row.part_id, kind).into());
448	let parent_part: Option<Box<str>> =
449		row.parent_part.as_deref().map(|p| strip_kind(p, kind).into());
450
451	SearchHit {
452		obj_tp: row.obj_tp,
453		obj_id: row.obj_id,
454		part_id,
455		part_kind: row.part_kind,
456		parent_part,
457		anchor_id: row.anchor_id,
458		app_id: content_type.as_deref().and_then(app_id_of).map(Into::into),
459		nav_param,
460		content_type,
461		title: row.title,
462		snippet: row.snippet,
463		snippet_matches: row.snippet_matches,
464		tags: row.tags.as_deref().map(split_tags),
465		owner_tag: row.owner_tag,
466		updated_at: row.updated_at,
467		// `bm25()` is negative and ascending-relevant; flip it so the client's
468		// "higher is better" intuition holds.
469		score: -row.score,
470	}
471}
472
473/// `cloudillo/notillo` → `notillo`. Anything else has no app to launch.
474fn app_id_of(content_type: &str) -> Option<&str> {
475	content_type.strip_prefix("cloudillo/").filter(|s| !s.is_empty())
476}
477
478/// Undo `indexer::build_parts`' `{kind}/{id}` namespacing for the wire.
479///
480/// The prefix exists only to keep `idx_search_docs_key` unique across
481/// collections; the client deep-links with the app's own document id.
482fn strip_kind<'a>(part: &'a str, kind: Option<&str>) -> &'a str {
483	kind.and_then(|k| part.strip_prefix(k)?.strip_prefix('/')).unwrap_or(part)
484}
485
486/// Map the API's `type` names onto `search_docs.obj_tp` codes. Unknown names
487/// are dropped rather than erroring, so a newer client asking for a type this
488/// build has never heard of degrades to "no such results" instead of a 400.
489///
490/// An all-unknown list therefore returns an *empty* vec, which the caller keeps
491/// as `Some(vec![])` — the adapter reads that as "match nothing". Collapsing it
492/// to `None` would turn `?type=quantum` into an unfiltered search, which is the
493/// opposite of what a filter the caller wrote should do.
494fn parse_types(raw: &str) -> Vec<char> {
495	raw.split(',')
496		.map(str::trim)
497		.filter_map(|t| match t {
498			"file" => Some('F'),
499			"doc" => Some('D'),
500			"action" => Some('A'),
501			"profile" => Some('P'),
502			_ => None,
503		})
504		.collect()
505}
506
507/// The mapping `get_search` applies to `?type=`: a blank or absent parameter is
508/// "no filter at all" (`None`), while a non-blank one naming nothing this build
509/// knows keeps its empty vec — which the adapter reads as "match nothing".
510///
511/// A free function rather than an inline expression so the test that guards the
512/// `Some(vec![])` / `None` distinction exercises the production path.
513fn obj_tp_filter(raw: Option<&str>) -> Option<Vec<char>> {
514	raw.map(str::trim).filter(|t| !t.is_empty()).map(parse_types)
515}
516
517/// Narrow an unauthenticated caller's `obj_tp` filter to the types a guest may
518/// see — everything except `'P'`.
519///
520/// The SQL prefilter exempts profiles from the visibility predicate on the
521/// grounds that any *authenticated* caller may already list them via
522/// `GET /api/profiles`; that does not hold for an anonymous one. A guest asking
523/// for `?type=profile` therefore ends with an empty vec, which the adapter reads
524/// as "match nothing" — an empty page, not an unfiltered one — and both
525/// `count_search` and `search` see the same `opts`, so the count agrees with it.
526fn guest_obj_tp(requested: Option<Vec<char>>) -> Vec<char> {
527	match requested {
528		Some(tps) => tps.into_iter().filter(|tp| *tp != OBJ_PROFILE).collect(),
529		None => vec![OBJ_FILE, OBJ_DOC, OBJ_ACTION],
530	}
531}
532
533/// Narrow a file-scoped caller's `obj_tp` filter to what a file scope can hold.
534///
535/// Intersect, don't replace: a scope narrows what the caller asked for, it does
536/// not answer a different question. A share-link holder asking `?type=action`
537/// gets an empty page, not a page of files. `Some(empty)` is "match nothing",
538/// which the adapter emits as `AND 1=0` — the same contract [`guest_obj_tp`]
539/// relies on, and both `count_search` and `search` see the same `opts`.
540fn scope_obj_tp(requested: Option<Vec<char>>) -> Vec<char> {
541	match requested {
542		Some(tps) => tps.into_iter().filter(|tp| matches!(*tp, OBJ_FILE | OBJ_DOC)).collect(),
543		None => vec![OBJ_FILE, OBJ_DOC],
544	}
545}
546
547/// Split one comma-separated filter list and bound it.
548///
549/// Rejected rather than truncated: a caller that asked for forty tags and was
550/// quietly served sixteen would get an answer to a question it did not ask. The
551/// cap matters because neither list is otherwise bounded — ~33k `contentType`
552/// values overrun SQLite's 32766 bound-variable limit into a 500, and a long
553/// `tags` list builds an arbitrarily deep FTS5 `MATCH` expression.
554fn csv_filter(raw: Option<&str>, max: usize, what: &str) -> ClResult<Option<Vec<String>>> {
555	let Some(values) = raw.map(split_csv) else { return Ok(None) };
556	if values.len() > max {
557		return Err(Error::ValidationError(format!("Too many {what} values (max {max})")));
558	}
559	if values.iter().any(|v| v.chars().count() > MAX_FILTER_ENTRY_CHARS) {
560		return Err(Error::ValidationError(format!("A {what} value is too long")));
561	}
562	Ok((!values.is_empty()).then_some(values))
563}
564
565fn split_csv(raw: &str) -> Vec<String> {
566	raw.split(',')
567		.map(str::trim)
568		.filter(|s| !s.is_empty())
569		.map(ToOwned::to_owned)
570		.collect()
571}
572
573fn split_tags(raw: &str) -> Box<[Box<str>]> {
574	raw.split_whitespace().map(Into::into).collect()
575}
576
577#[cfg(test)]
578mod tests {
579	use super::*;
580
581	#[test]
582	fn type_names_map_to_obj_tp_codes() {
583		assert_eq!(parse_types("file,doc"), vec!['F', 'D']);
584		assert_eq!(parse_types(" action , profile "), vec!['A', 'P']);
585	}
586
587	#[test]
588	fn unknown_type_names_are_dropped_not_rejected() {
589		assert_eq!(parse_types("doc,quantum"), vec!['D']);
590		assert!(parse_types("quantum").is_empty());
591	}
592
593	/// An all-unknown list must stay `Some(vec![])` — "match nothing"; only a
594	/// blank one may become `None`, which is "no filter at all".
595	#[test]
596	fn an_all_unknown_type_filter_matches_nothing_rather_than_everything() {
597		assert_eq!(obj_tp_filter(Some("quantum")), Some(vec![]));
598		assert_eq!(obj_tp_filter(Some("quantum,warp")), Some(vec![]));
599		// A blank or absent parameter is the only "no filter" case.
600		assert_eq!(obj_tp_filter(Some("")), None);
601		assert_eq!(obj_tp_filter(Some("  ")), None);
602		assert_eq!(obj_tp_filter(None), None);
603		// A partially-recognised list keeps what it understood.
604		assert_eq!(obj_tp_filter(Some("doc,quantum")), Some(vec!['D']));
605	}
606
607	/// A page that filled up may or may not be the last one, and no page past the
608	/// first says anything about the size of the match set.
609	#[test]
610	fn the_second_fts_scan_is_skipped_only_for_a_short_first_page() {
611		// Short first page: the page *is* the match set.
612		assert_eq!(total_from_page(0, 3, 20), Some(3));
613		assert_eq!(total_from_page(0, 0, 20), Some(0));
614		assert_eq!(total_from_page(0, 19, 20), Some(19));
615
616		// Full first page: there may be more behind it.
617		assert_eq!(total_from_page(0, 20, 20), None);
618
619		// Any later page, however short: `offset + len` is not the total.
620		assert_eq!(total_from_page(20, 3, 20), None);
621		assert_eq!(total_from_page(20, 0, 20), None);
622		assert_eq!(total_from_page(1, 0, 20), None);
623	}
624
625	/// An explicit `?type=profile` becomes "match nothing" rather than falling
626	/// back to "no filter".
627	#[test]
628	fn a_guest_never_sees_profiles() {
629		assert_eq!(guest_obj_tp(None), vec![OBJ_FILE, OBJ_DOC, OBJ_ACTION]);
630		assert_eq!(guest_obj_tp(Some(vec!['P'])), Vec::<char>::new());
631		assert_eq!(guest_obj_tp(Some(vec!['F', 'P'])), vec!['F']);
632		assert_eq!(guest_obj_tp(Some(vec![])), Vec::<char>::new());
633	}
634
635	/// Asking for actions inside a file scope is an empty page, not a page of
636	/// files the caller never asked for.
637	#[test]
638	fn a_file_scope_intersects_the_type_filter() {
639		assert_eq!(scope_obj_tp(None), vec![OBJ_FILE, OBJ_DOC]);
640		assert_eq!(scope_obj_tp(Some(vec!['A'])), Vec::<char>::new());
641		assert_eq!(scope_obj_tp(Some(vec!['F'])), vec!['F']);
642		assert_eq!(scope_obj_tp(Some(vec!['F', 'A', 'D'])), vec!['F', 'D']);
643		assert_eq!(scope_obj_tp(Some(vec![])), Vec::<char>::new());
644	}
645
646	/// A share-link token resolves its `id_tag` to the tenant's own, so testing
647	/// the subject alone would derive `Owner` for every share-link guest. The
648	/// scope is what tells the two apart.
649	#[test]
650	fn a_scoped_token_is_never_the_owner() {
651		let tenant = "alice.example.com";
652		assert_eq!(
653			subject_level(Some("file:f1~x:R"), tenant, tenant, false, false),
654			SubjectAccessLevel::Public
655		);
656		assert_eq!(subject_level(None, tenant, tenant, false, false), SubjectAccessLevel::Owner);
657	}
658
659	/// Getting this wrong pushes `OR d.owner_tag = '<tenant>'` into the adapter's
660	/// visibility predicate and hands an anonymous link holder owner-equivalent
661	/// visibility on every row carrying the tenant's tag.
662	#[test]
663	fn an_anonymous_share_is_not_handed_the_tenants_own_tag() {
664		let tenant = "alice.example.com";
665		// A scoped token naming nobody resolves its subject to the tenant.
666		assert!(is_anonymous_share(Some("file:f1~x:R"), tenant, tenant));
667		// A logged-in user holding a file-scoped credential is still themselves.
668		assert!(!is_anonymous_share(Some("file:f1~x:R"), "bob.example.com", tenant));
669		// An unscoped call by the tenant is the real owner.
670		assert!(!is_anonymous_share(None, tenant, tenant));
671		assert!(!is_anonymous_share(None, "bob.example.com", tenant));
672	}
673
674	/// The two derivations agree by construction: whenever `is_anonymous_share`
675	/// holds, `subject_level` drops to `Public` and the caller gets no viewer tag.
676	#[test]
677	fn the_anonymity_test_and_the_visibility_level_agree() {
678		let tenant = "alice.example.com";
679		for scope in [None, Some("file:f1~x:R")] {
680			for subject in [tenant, "bob.example.com"] {
681				if is_anonymous_share(scope, subject, tenant) {
682					assert_eq!(
683						subject_level(scope, subject, tenant, false, false),
684						SubjectAccessLevel::Public
685					);
686				}
687			}
688		}
689	}
690
691	/// A scope does not *lower* a caller below what their relationship earns
692	/// either — it only removes the owner shortcut.
693	#[test]
694	fn a_scope_leaves_the_relationship_levels_alone() {
695		let tenant = "alice.example.com";
696		assert_eq!(
697			subject_level(Some("file:f1~x:R"), "bob.example.com", tenant, true, false),
698			SubjectAccessLevel::Connected
699		);
700		assert_eq!(
701			subject_level(Some("file:f1~x:R"), "bob.example.com", tenant, false, true),
702			SubjectAccessLevel::Follower
703		);
704		assert_eq!(subject_level(None, "guest", tenant, false, false), SubjectAccessLevel::Public);
705		assert_eq!(
706			subject_level(None, "bob.example.com", tenant, false, false),
707			SubjectAccessLevel::Verified
708		);
709	}
710
711	#[test]
712	fn an_over_long_filter_list_is_rejected() {
713		let tags = (0..=SEARCH_MAX_TAGS).map(|i| format!("t{i}")).collect::<Vec<_>>().join(",");
714		assert!(matches!(
715			csv_filter(Some(&tags), SEARCH_MAX_TAGS, "tags"),
716			Err(Error::ValidationError(_))
717		));
718		// Exactly at the cap is fine.
719		let tags = (0..SEARCH_MAX_TAGS).map(|i| format!("t{i}")).collect::<Vec<_>>().join(",");
720		assert_eq!(
721			csv_filter(Some(&tags), SEARCH_MAX_TAGS, "tags").expect("ok").map(|v| v.len()),
722			Some(SEARCH_MAX_TAGS)
723		);
724	}
725
726	#[test]
727	fn an_over_long_filter_entry_is_rejected() {
728		let long = "x".repeat(MAX_FILTER_ENTRY_CHARS + 1);
729		assert!(matches!(
730			csv_filter(Some(&long), SEARCH_MAX_TAGS, "tags"),
731			Err(Error::ValidationError(_))
732		));
733	}
734
735	#[test]
736	fn an_empty_filter_list_is_no_filter() {
737		assert_eq!(csv_filter(None, SEARCH_MAX_TAGS, "tags").expect("ok"), None);
738		assert_eq!(csv_filter(Some(" , "), SEARCH_MAX_TAGS, "tags").expect("ok"), None);
739	}
740
741	#[test]
742	fn app_id_is_parsed_only_from_the_cloudillo_namespace() {
743		assert_eq!(app_id_of("cloudillo/notillo"), Some("notillo"));
744		assert_eq!(app_id_of("application/pdf"), None);
745		assert_eq!(app_id_of("cloudillo/"), None);
746	}
747
748	#[test]
749	fn the_kind_prefix_comes_off_the_wire_value() {
750		assert_eq!(strip_kind("p/page1", Some("p")), "page1");
751		// A row written before the namespacing, or one whose id happens not to
752		// carry the prefix, passes through untouched.
753		assert_eq!(strip_kind("page1", Some("p")), "page1");
754		assert_eq!(strip_kind("p/page1", None), "p/page1");
755	}
756}
757
758// vim: ts=4