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