Skip to main content

cloudillo_search/
rules.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! The document-format index manifest: what an app declares it wants indexed.
5//!
6//! The manifest is stored as JSON in `doc_formats.search` and parsed into
7//! [`IndexRules`] here. It names **RTDB collections** (`kind`) and the fields
8//! within their documents that carry text, so indexing a new app needs no Rust.
9//!
10//! A worked example — notillo, whose pages live in collection `p` and whose
11//! blocks live in `b` and point at their page through field `p`:
12//!
13//! ```json
14//! {
15//!   "v": 1,
16//!   "parts": [
17//!     { "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
18//!     { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
19//!       "anchor": "docId", "order": ["o"],
20//!       "prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
21//!       "body": [
22//!         { "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] },
23//!         { "path": "$..tg", "extract": "string", "prefix": "#" },
24//!         { "path": "pr.caption" }
25//!       ] }
26//!   ],
27//!   "limits": { "maxParts": 5000, "maxBodyChars": 100000 }
28//! }
29//! ```
30//!
31//! A part **without** `attachTo` emits one index row per document. A part
32//! **with** `attachTo` emits nothing of its own — its text is folded into the
33//! body of the owning part's row. That is what makes the index *deep*: block
34//! text lands on the page row, so a hit deep-links to the page.
35//!
36//! # Field rules
37//!
38//! A field rule's `path` (spelled `field` in manifests written before the
39//! rename, which is still accepted) is either a **dotted path** (`"pr.caption"`,
40//! numeric segments indexing arrays) or an **RFC 9535 JSONPath query**, told
41//! apart by the leading `$` the standard requires. `extract` then says how the
42//! selected nodes become text: `"text"` (the default) walks the node for string
43//! leaves, `"string"` takes the node verbatim and skips it if it is not a string.
44//!
45//! Four modifiers shape what a walk emits. `keys` is an allowlist of object keys
46//! whose strings are prose, `excludeKeys` a denylist of keys whose subtree is
47//! skipped, `prefixKeys` prefixes the strings under a named key, and `prefix`
48//! prefixes every token the rule emits. The modifiers compose, so a manifest can
49//! spend one rule per text source rather than making one rule do everything —
50//! but note that [`crate::extract::extract_fields`] appends every rule's output
51//! into one sink **in declaration order**, so a single prose stream must stay a
52//! single rule. Splitting one would scramble reading order; only metadata (tags,
53//! captions) belongs in a rule of its own, trailing the text it annotates.
54//!
55//! # Pruning
56//!
57//! A part rule's `prune` is a list of RFC 9535 queries whose matches are
58//! **deleted** from the document before any field rule sees it. It exists for the
59//! one thing `keys` structurally cannot do: `keys` gates by *name*, prune gates by
60//! *position*. notillo stores a styled run as the positional tuple
61//! `["szöveg", "b"]`, so the style flag shares its text's enclosing key and no
62//! allowlist can separate them — but `$..c[0:][1:]` names the tail slot directly.
63//!
64//! Deletion-only is what keeps it safe. A prune pattern can remove text; it can
65//! never reorder or fabricate any, so the single-ordered-walk invariant the field
66//! rules rest on survives untouched. A pattern that fails at run time therefore
67//! degrades to "the flag tokens stay in the index", which is the status quo.
68//!
69//! **Prefer the slice `[0:]` to the wildcard `[*]`.** A slice is inert on
70//! anything that is not an array (`process_slice` ends in
71//! `as_array().map(…).unwrap_or_default()`); a wildcard descends objects too.
72//! notillo's table block stores its content as the *object*
73//! `{"type": "tableContent", "cw": […], "rows": [{"cells": […]}]}` under the same
74//! `c` key an ordinary block uses for its inline array. `$..c[0:][1:]` sees
75//! nothing there and leaves the table alone, whereas `$..c[*][1:]` would descend
76//! into that object, reach `rows`, and delete every row but the first.
77//!
78//! See [`crate::prune`] for the evaluation order and the error handling.
79//!
80//! The same [`FieldRule`] vocabulary is reused for actions, whose manifests
81//! come from the Action DSL rather than from `doc_formats` — see
82//! [`ActionSearchRules`].
83
84use std::collections::HashMap;
85
86use serde::Deserialize;
87
88use crate::prelude::*;
89
90/// Sentinel `anchor` / `order` value meaning "the document's own RTDB id"
91/// rather than a field inside it. `export_all` returns ids as keys, not as a
92/// field, so there is nothing else to name them by.
93pub const DOC_ID: &str = "docId";
94
95/// Manifest version this build understands. A higher `v` is refused rather
96/// than half-applied.
97///
98/// Platform-owned, and unrelated to `DocFormat::format_version`: this versions
99/// the rules DSL itself, that one versions an app's document format.
100pub const SUPPORTED_VERSION: u32 = 1;
101
102// Safety limits on the manifest itself, so a hostile or buggy registration
103// cannot make indexing pathological.
104const MAX_PART_RULES: usize = 32;
105const MAX_FIELD_RULES: usize = 32;
106/// Most `keys` / `excludeKeys` / `prefixKeys` entries one field rule may carry.
107/// The first two are scanned linearly at every node the walk touches, and no
108/// output budget bounds that scan — a leaf gated out costs the scan and pushes
109/// nothing, so `max_body_chars` does not cover it. A FortuneSheet cell has ~25
110/// keys and a BlockNote block ~15; this is several times the largest real schema.
111const MAX_KEY_RULES: usize = 64;
112/// Most `prune` patterns one part rule may carry.
113///
114/// Each pattern is a **whole extra traversal** of every document of its kind,
115/// plus an allocated normalised path per match and a reparse of it — a direct
116/// multiplier on per-document indexing cost that nothing downstream bounds
117/// (`MAX_JSONPATH_NODES` caps a field rule's match set, but a deletion's is built
118/// inside `delete_by_path` where there is no hook). notillo needs two: one for
119/// inline content, one for an array-form table row's cells.
120const MAX_PRUNE_RULES: usize = 8;
121/// Most `order` fields one part rule may carry. Each is one `sort_key_of`
122/// resolution per contribution — up to `MAX_CONTRIBUTIONS` of them — and then one
123/// more element in every comparison of the sort that follows. A direct multiplier
124/// on per-document indexing cost, with nothing downstream to bound it.
125const MAX_ORDER_FIELDS: usize = 8;
126const MAX_PATH_SEGMENTS: usize = 8;
127const MAX_EXTRACT_DEPTH: usize = 32;
128/// Longest accepted RFC 9535 query text. Long enough for any realistic
129/// selector, short enough that parsing one cannot become the expensive part of
130/// a registration.
131const MAX_JSONPATH_LEN: usize = 256;
132/// Most nodes one JSONPath field rule may emit out of one document. Past this
133/// the extraction truncates.
134///
135/// A backstop, not the primary bound. Everything a lower value looks like it
136/// would save is already bounded elsewhere: the engine has collected the whole
137/// match set by the time this applies, each match is walked no deeper than
138/// `max_depth`, and the text out is capped by `max_body_chars`. What a low value
139/// does buy is **silent** text loss. Sized past one node per non-empty cell of a
140/// large spreadsheet and per inline node of a long document: at 1024 a 40×30
141/// sheet already lost text.
142pub(crate) const MAX_JSONPATH_NODES: usize = 65_536;
143
144/// Defaults for [`Limits`], applied when the manifest omits them — and, because
145/// `validate` clamps every manifest-supplied limit to `clamp(1, DEFAULT_MAX_*)`,
146/// also the ceiling a manifest may ask for. A manifest can only lower them.
147///
148/// They are deliberately modest: indexed text is stored twice — once as the
149/// plain-text extract in `search_docs.body`, once in the FTS5 positional index —
150/// on top of whatever the document store already holds, so a generous total lets
151/// a single document triple its own textual footprint on disk.
152const DEFAULT_MAX_PARTS: usize = 5000;
153const DEFAULT_MAX_BODY_CHARS: usize = 32_000;
154const DEFAULT_MAX_TOTAL_CHARS: usize = 512_000;
155const DEFAULT_EXTRACT_DEPTH: usize = 16;
156
157/// Parsed and validated index manifest.
158#[derive(Debug, Clone)]
159pub struct IndexRules {
160	pub parts: Vec<PartRule>,
161	pub limits: Limits,
162}
163
164/// One collection's indexing rule.
165#[derive(Debug, Clone)]
166pub struct PartRule {
167	/// RTDB collection name.
168	pub kind: String,
169	/// When set, this rule contributes text to another part instead of
170	/// emitting rows of its own.
171	pub attach_to: Option<AttachTo>,
172	/// Field (or [`DOC_ID`]) recorded as the row's `anchor_id`. Only the first
173	/// contributing document wins, so the anchor points at the first hit.
174	pub anchor: Option<String>,
175	/// Fields to sort contributions by, so an assembled body follows reading
176	/// order.
177	pub order: Vec<String>,
178	/// Field naming this document's parent, for tree display of results.
179	pub parent: Option<String>,
180	/// RFC 9535 queries whose matches are **deleted** from a document of this
181	/// kind before any field rule below sees it. See [`crate::prune`].
182	///
183	/// Kept as the query *text*: `Queryable::delete_by_path` takes `&str` and
184	/// reparses it itself, so a compiled `JpQuery` stored here would never be
185	/// evaluated. It is still compiled once at registration — so a malformed
186	/// pattern is a 4xx on the manifest rather than a per-document `warn!`
187	/// forever after — and then dropped.
188	pub prune: Vec<String>,
189	pub title: Vec<FieldRule>,
190	pub body: Vec<FieldRule>,
191	pub tags: Vec<FieldRule>,
192}
193
194/// Where an attached part's text goes.
195#[derive(Debug, Clone)]
196pub struct AttachTo {
197	/// The owning part's `kind`.
198	pub kind: String,
199	/// Field on *this* document holding the owner's document id.
200	pub field: String,
201}
202
203/// What a field rule's `field` string selects out of a document.
204///
205/// A `field` starting with `$` is compiled as RFC 9535 JSONPath; anything else
206/// keeps the original dotted-path behaviour. RFC 9535 requires a query to start
207/// with `$` and no dotted path does, so the discrimination is unambiguous and
208/// every manifest written before JSONPath existed still parses the same way.
209#[derive(Debug, Clone)]
210pub enum Selector {
211	/// Pre-split dotted path; numeric segments index arrays. Empty = whole
212	/// document.
213	Dotted(Vec<String>),
214	/// Compiled RFC 9535 query. Compiled once here so a document loop never
215	/// reparses it. Boxed because `JpQuery` is much larger than a `Vec`, and a
216	/// `FieldRule` is cloned per part rule.
217	JsonPath(Box<jsonpath_rust::parser::model::JpQuery>),
218}
219
220/// How the text of a selected node is taken.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum ExtractMode {
223	/// Recursive string-leaf walk: descend the node and concatenate every string
224	/// in it. The default, and what a bare path entry uses.
225	#[default]
226	Text,
227	/// The node must itself be a JSON string, taken verbatim with no descent.
228	/// Non-strings are skipped. Use this where a walk would pull in structural
229	/// noise from around the one string that matters.
230	String,
231}
232
233/// One text source within a document.
234#[derive(Debug, Clone)]
235pub struct FieldRule {
236	/// What this rule selects. See [`Selector`].
237	pub selector: Selector,
238	/// How the selected nodes are turned into text. See [`ExtractMode`].
239	pub mode: ExtractMode,
240	/// Object keys whose string values are indexed. Empty means every key — the
241	/// pre-`keys` behaviour.
242	///
243	/// **Strings not under any object key are always indexed**: elements of the
244	/// selected array, and the selected node when it is itself a string. Nothing
245	/// names them, so an allowlist has nothing to match — without this, setting
246	/// `keys` would lose notillo's plainest text (a bare `CompactInlineContent`
247	/// string) and every string-selecting rule such as `pr.caption`.
248	///
249	/// This is *not* the mirror of `exclude_keys`: that prunes a subtree, this
250	/// gates a leaf. Containers are always descended, so a document with dynamic
251	/// keys (calcillo's `rows.<rowId>.<colId>`) still reaches its text. It cannot
252	/// separate the members of a positional tuple like `["Hi", "b"]` — both carry
253	/// the same enclosing key — unless the part rule deletes one first; see
254	/// [`PartRule::prune`]. Only meaningful for [`ExtractMode::Text`].
255	pub keys: Vec<String>,
256	/// Object keys whose values are skipped entirely (link targets, colors…).
257	/// Only meaningful for [`ExtractMode::Text`].
258	pub exclude_keys: Vec<String>,
259	/// Constant prefix on every token this rule emits, e.g. `"#"` on a rule
260	/// selecting tag values. Unlike `prefix_keys` it survives [`ExtractMode::String`]
261	/// and a JSONPath selector that lands on the value itself, where there is no
262	/// key left to match.
263	pub prefix: String,
264	/// Object keys whose string values get a prefix — e.g. `{"tg": "#"}` turns
265	/// a tag node's text into a `#tag` token.
266	pub prefix_keys: HashMap<String, String>,
267	pub max_depth: usize,
268}
269
270impl FieldRule {
271	/// A plain dotted-path, whole-document-walk rule — the shape a bare string
272	/// entry in a manifest produces.
273	pub fn dotted(field: &str) -> Self {
274		Self {
275			selector: Selector::Dotted(split_dotted(field)),
276			mode: ExtractMode::Text,
277			keys: Vec::new(),
278			exclude_keys: Vec::new(),
279			prefix: String::new(),
280			prefix_keys: HashMap::new(),
281			max_depth: DEFAULT_EXTRACT_DEPTH,
282		}
283	}
284}
285
286/// Split a dotted path into its non-empty segments.
287fn split_dotted(field: &str) -> Vec<String> {
288	field.split('.').filter(|s| !s.is_empty()).map(ToOwned::to_owned).collect()
289}
290
291/// Guard rails. Exceeding any of these truncates and warns; it never fails the
292/// index run, because a partially indexed document beats an unindexed one.
293#[derive(Debug, Clone, Copy)]
294pub struct Limits {
295	pub max_parts: usize,
296	pub max_body_chars: usize,
297	pub max_total_chars: usize,
298}
299
300impl Default for Limits {
301	fn default() -> Self {
302		Self {
303			max_parts: DEFAULT_MAX_PARTS,
304			max_body_chars: DEFAULT_MAX_BODY_CHARS,
305			max_total_chars: DEFAULT_MAX_TOTAL_CHARS,
306		}
307	}
308}
309
310impl IndexRules {
311	/// Parse and validate a manifest.
312	pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
313		let raw: RawRules = serde_json::from_value(value.clone())
314			.map_err(|e| Error::ValidationError(format!("invalid search manifest: {e}")))?;
315		raw.validate()
316	}
317
318	/// The rule emitting rows for `kind`, if any.
319	pub fn owner_rule(&self, kind: &str) -> Option<&PartRule> {
320		self.parts.iter().find(|p| p.kind == kind && p.attach_to.is_none())
321	}
322}
323
324/// An action type's search manifest, declared in the Action DSL's
325/// `ActionDefinition::search` and parsed here.
326///
327/// An action is one object with no sub-parts, so there is no `parts` layer and
328/// no `attachTo`: the manifest is just the three field-rule lists. They are
329/// applied to a **wrapper document** assembled by [`crate::objects`], not to the
330/// action's `content` alone, so a rule can reach the type, the issuer or the
331/// attachments as well:
332///
333/// ```json
334/// { "content": <parsed content>, "type": "POST", "subType": "TEXT",
335///   "issuerTag": "…", "audienceTag": "…", "subject": "…", "attachments": [] }
336/// ```
337///
338/// A type with no `search` block is simply not indexed; that absence is the only
339/// action-type allowlist there is.
340#[derive(Debug, Clone, Default)]
341pub struct ActionSearchRules {
342	pub title: Vec<FieldRule>,
343	pub body: Vec<FieldRule>,
344	pub tags: Vec<FieldRule>,
345}
346
347impl ActionSearchRules {
348	/// Parse and validate one action type's manifest.
349	pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
350		let raw: RawActionRules = serde_json::from_value(value.clone())
351			.map_err(|e| Error::ValidationError(format!("invalid action search manifest: {e}")))?;
352		raw.validate()
353	}
354
355	/// Whether the manifest selects anything at all. An all-empty one would
356	/// index every action of the type as a textless row.
357	pub fn is_empty(&self) -> bool {
358		self.title.is_empty() && self.body.is_empty() && self.tags.is_empty()
359	}
360}
361
362#[derive(Debug, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364struct RawActionRules {
365	#[serde(default = "default_version")]
366	v: u32,
367	#[serde(default)]
368	title: Vec<RawField>,
369	#[serde(default)]
370	body: Vec<RawField>,
371	#[serde(default)]
372	tags: Vec<RawField>,
373}
374
375impl RawActionRules {
376	fn validate(self) -> ClResult<ActionSearchRules> {
377		if self.v > SUPPORTED_VERSION {
378			return Err(Error::ValidationError(format!(
379				"action search manifest version {} is newer than supported version \
380				 {SUPPORTED_VERSION}",
381				self.v
382			)));
383		}
384		let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
385			if raw.len() > MAX_FIELD_RULES {
386				return Err(Error::ValidationError(format!(
387					"action search manifest has {} {what} rules, max {MAX_FIELD_RULES}",
388					raw.len()
389				)));
390			}
391			raw.into_iter().map(RawField::validate).collect()
392		};
393		let rules = ActionSearchRules {
394			title: fields(self.title, "title")?,
395			body: fields(self.body, "body")?,
396			tags: fields(self.tags, "tags")?,
397		};
398		if rules.is_empty() {
399			return Err(Error::ValidationError(
400				"action search manifest selects no fields; omit it instead".into(),
401			));
402		}
403		Ok(rules)
404	}
405}
406
407// --- wire shapes -----------------------------------------------------------
408// Deserialized verbatim, then converted by `validate()`. Keeping the raw and
409// validated shapes apart means the rest of the crate can never see an
410// unvalidated rule.
411
412#[derive(Debug, Deserialize)]
413#[serde(rename_all = "camelCase", deny_unknown_fields)]
414struct RawRules {
415	#[serde(default = "default_version")]
416	v: u32,
417	#[serde(default)]
418	parts: Vec<RawPart>,
419	#[serde(default)]
420	limits: Option<RawLimits>,
421}
422
423fn default_version() -> u32 {
424	SUPPORTED_VERSION
425}
426
427#[derive(Debug, Deserialize)]
428#[serde(rename_all = "camelCase", deny_unknown_fields)]
429struct RawPart {
430	kind: String,
431	#[serde(default)]
432	attach_to: Option<RawAttachTo>,
433	#[serde(default)]
434	anchor: Option<String>,
435	#[serde(default)]
436	order: Vec<String>,
437	#[serde(default)]
438	parent: Option<String>,
439	#[serde(default)]
440	prune: Vec<String>,
441	#[serde(default)]
442	title: Vec<RawField>,
443	#[serde(default)]
444	body: Vec<RawField>,
445	#[serde(default)]
446	tags: Vec<RawField>,
447}
448
449#[derive(Debug, Deserialize)]
450#[serde(rename_all = "camelCase", deny_unknown_fields)]
451struct RawAttachTo {
452	kind: String,
453	field: String,
454}
455
456/// A field entry is either a bare dotted path or the full object form.
457///
458/// Hand-written rather than `#[serde(untagged)]`: an untagged enum reports every
459/// failure as "data did not match any variant", so one mistyped key becomes an
460/// unactionable error on an app author's registration. Dispatching on the JSON
461/// shape lets [`RawFullField`]'s own `deny_unknown_fields` message through.
462#[derive(Debug)]
463pub(crate) enum RawField {
464	Path(String),
465	Full(RawFullField),
466}
467
468#[derive(Debug, Deserialize)]
469#[serde(rename_all = "camelCase", deny_unknown_fields)]
470pub(crate) struct RawFullField {
471	/// Dotted path or RFC 9535 query. `field` is the pre-rename spelling, kept
472	/// because stored manifests use it.
473	#[serde(alias = "field")]
474	path: String,
475	/// `"text"` (the recursive string-leaf walk, the default a bare path entry
476	/// also uses) or `"string"` (the node taken verbatim).
477	#[serde(default)]
478	extract: Option<String>,
479	#[serde(default)]
480	keys: Vec<String>,
481	#[serde(default)]
482	exclude_keys: Vec<String>,
483	#[serde(default)]
484	prefix: Option<String>,
485	#[serde(default)]
486	prefix_keys: HashMap<String, String>,
487	#[serde(default)]
488	max_depth: Option<usize>,
489}
490
491impl<'de> Deserialize<'de> for RawField {
492	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
493		use serde::de::Error as _;
494		match serde_json::Value::deserialize(de)? {
495			serde_json::Value::String(path) => Ok(Self::Path(path)),
496			other => serde_json::from_value(other).map(Self::Full).map_err(D::Error::custom),
497		}
498	}
499}
500
501#[derive(Debug, Deserialize)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503#[allow(clippy::struct_field_names)]
504struct RawLimits {
505	#[serde(default)]
506	max_parts: Option<usize>,
507	#[serde(default)]
508	max_body_chars: Option<usize>,
509	#[serde(default)]
510	max_total_chars: Option<usize>,
511}
512
513impl RawRules {
514	fn validate(self) -> ClResult<IndexRules> {
515		if self.v > SUPPORTED_VERSION {
516			return Err(Error::ValidationError(format!(
517				"search manifest version {} is newer than supported version {SUPPORTED_VERSION}",
518				self.v
519			)));
520		}
521		if self.parts.is_empty() {
522			return Err(Error::ValidationError("search manifest has no parts".into()));
523		}
524		if self.parts.len() > MAX_PART_RULES {
525			return Err(Error::ValidationError(format!(
526				"search manifest has {} part rules, max {MAX_PART_RULES}",
527				self.parts.len()
528			)));
529		}
530
531		let parts = self.parts.into_iter().map(RawPart::validate).collect::<ClResult<Vec<_>>>()?;
532
533		// Every `attachTo` must name a part that actually emits rows, otherwise
534		// its text would be extracted and silently dropped.
535		for part in &parts {
536			let Some(attach) = &part.attach_to else { continue };
537			let owner_exists = parts.iter().any(|p| p.kind == attach.kind && p.attach_to.is_none());
538			if !owner_exists {
539				return Err(Error::ValidationError(format!(
540					"part '{}' attaches to '{}', which is not an emitting part",
541					part.kind, attach.kind
542				)));
543			}
544		}
545
546		// Two emitting rules for one collection would race for the same
547		// `(obj_id, part_id)` key.
548		let mut emitting: Vec<&str> = parts
549			.iter()
550			.filter(|p| p.attach_to.is_none())
551			.map(|p| p.kind.as_str())
552			.collect();
553		emitting.sort_unstable();
554		if emitting.windows(2).any(|w| w[0] == w[1]) {
555			return Err(Error::ValidationError(
556				"search manifest has two emitting rules for the same kind".into(),
557			));
558		}
559
560		let defaults = Limits::default();
561		let limits = self.limits.map_or(defaults, |l| Limits {
562			max_parts: l.max_parts.unwrap_or(defaults.max_parts).clamp(1, DEFAULT_MAX_PARTS),
563			max_body_chars: l
564				.max_body_chars
565				.unwrap_or(defaults.max_body_chars)
566				.clamp(1, DEFAULT_MAX_BODY_CHARS),
567			max_total_chars: l
568				.max_total_chars
569				.unwrap_or(defaults.max_total_chars)
570				.clamp(1, DEFAULT_MAX_TOTAL_CHARS),
571		});
572
573		Ok(IndexRules { parts, limits })
574	}
575}
576
577impl RawPart {
578	fn validate(self) -> ClResult<PartRule> {
579		if self.kind.is_empty() {
580			return Err(Error::ValidationError("part rule has an empty kind".into()));
581		}
582		if self.prune.len() > MAX_PRUNE_RULES {
583			return Err(Error::ValidationError(format!(
584				"part '{}' has {} prune patterns, max {MAX_PRUNE_RULES}",
585				self.kind,
586				self.prune.len()
587			)));
588		}
589		for pattern in &self.prune {
590			validate_prune(pattern)?;
591		}
592		if self.order.len() > MAX_ORDER_FIELDS {
593			return Err(Error::ValidationError(format!(
594				"part '{}' has {} order fields, max {MAX_ORDER_FIELDS}",
595				self.kind,
596				self.order.len()
597			)));
598		}
599		// `order`, `anchor` and `parent` are dotted paths resolved per contribution,
600		// so they need the same segment cap a field rule's path gets. `DOC_ID` is a
601		// single segment, so it needs no special case here.
602		for (what, path) in self
603			.order
604			.iter()
605			.map(|p| ("order", p))
606			.chain(self.anchor.iter().map(|p| ("anchor", p)))
607			.chain(self.parent.iter().map(|p| ("parent", p)))
608		{
609			if path.is_empty() {
610				return Err(Error::ValidationError(format!(
611					"part '{}' has an empty {what} path",
612					self.kind
613				)));
614			}
615			let segments = split_dotted(path);
616			if segments.len() > MAX_PATH_SEGMENTS {
617				return Err(Error::ValidationError(format!(
618					"part '{}' {what} path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
619					self.kind,
620					segments.len()
621				)));
622			}
623		}
624		let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
625			if raw.len() > MAX_FIELD_RULES {
626				return Err(Error::ValidationError(format!(
627					"part '{}' has {} {what} rules, max {MAX_FIELD_RULES}",
628					self.kind,
629					raw.len()
630				)));
631			}
632			raw.into_iter().map(RawField::validate).collect()
633		};
634
635		Ok(PartRule {
636			attach_to: self.attach_to.map(|a| AttachTo { kind: a.kind, field: a.field }),
637			anchor: self.anchor,
638			order: self.order,
639			parent: self.parent,
640			prune: self.prune,
641			title: fields(self.title, "title")?,
642			body: fields(self.body, "body")?,
643			tags: fields(self.tags, "tags")?,
644			kind: self.kind,
645		})
646	}
647}
648
649/// Check one `prune` pattern.
650fn validate_prune(pattern: &str) -> ClResult<()> {
651	// The same '$' discriminator a field selector uses, but here it is a hard
652	// requirement rather than a dispatch: a prune entry has no dotted-path form,
653	// so a pattern without it is a typo, not a second syntax.
654	if !pattern.starts_with('$') {
655		return Err(Error::ValidationError(format!(
656			"prune pattern '{pattern}' must be a JSONPath query starting with '$'"
657		)));
658	}
659	// `delete_by_path` maps the bare root to `DeletionInfo::Root`, which replaces
660	// the whole document with `null`. No other pattern can reach that arm, because
661	// every selector appends a segment to the normalised path it reports, so
662	// refusing this one string is enough.
663	if pattern == "$" {
664		return Err(Error::ValidationError(
665			"prune pattern '$' would delete the whole document".into(),
666		));
667	}
668	if pattern.len() > MAX_JSONPATH_LEN {
669		return Err(Error::ValidationError(format!(
670			"prune pattern is {} chars, max {MAX_JSONPATH_LEN}",
671			pattern.len()
672		)));
673	}
674	jsonpath_rust::parser::parse_json_path(pattern)
675		.map_err(|e| Error::ValidationError(format!("invalid prune pattern '{pattern}': {e}")))?;
676	Ok(())
677}
678
679impl RawField {
680	pub(crate) fn validate(self) -> ClResult<FieldRule> {
681		// A bare path entry is the full form with every modifier at its default,
682		// so there is one code path from here on.
683		let RawFullField { path, extract, keys, exclude_keys, prefix, prefix_keys, max_depth } =
684			match self {
685				Self::Path(path) => RawFullField {
686					path,
687					extract: None,
688					keys: Vec::new(),
689					exclude_keys: Vec::new(),
690					prefix: None,
691					prefix_keys: HashMap::new(),
692					max_depth: None,
693				},
694				Self::Full(full) => full,
695			};
696
697		let mode = match extract.as_deref() {
698			None | Some("text") => ExtractMode::Text,
699			Some("string") => ExtractMode::String,
700			Some(mode) => {
701				return Err(Error::ValidationError(format!("unknown extract mode '{mode}'")));
702			}
703		};
704
705		let cap = |n: usize, what: &str| -> ClResult<()> {
706			if n > MAX_KEY_RULES {
707				return Err(Error::ValidationError(format!(
708					"field '{path}' has {n} {what} entries, max {MAX_KEY_RULES}"
709				)));
710			}
711			Ok(())
712		};
713		cap(keys.len(), "keys")?;
714		cap(exclude_keys.len(), "excludeKeys")?;
715		cap(prefix_keys.len(), "prefixKeys")?;
716
717		// A leading '$' is what RFC 9535 requires of every query and what no
718		// dotted path ever starts with, so it is the discriminator.
719		let selector = if path.starts_with('$') {
720			if path.len() > MAX_JSONPATH_LEN {
721				return Err(Error::ValidationError(format!(
722					"JSONPath query is {} chars, max {MAX_JSONPATH_LEN}",
723					path.len()
724				)));
725			}
726			// Compiled here, at registration, so a malformed query is a 4xx on the
727			// manifest rather than a per-document warning forever after — and so
728			// indexing a document never reparses it.
729			let query = jsonpath_rust::parser::parse_json_path(&path).map_err(|e| {
730				Error::ValidationError(format!("invalid JSONPath query '{path}': {e}"))
731			})?;
732			// The one manifest cost the caps above do not budget: jsonpath-rust 1.0
733			// calls `Regex::new` *inside* the per-node comparison rather than
734			// compiling once, so a `match()`/`search()` filter pays one regex
735			// compilation per node of every document indexed, forever.
736			// `MAX_JSONPATH_NODES` caps the result set, not the nodes visited.
737			//
738			// A text scan rather than an AST walk: the input is already capped at
739			// `MAX_JSONPATH_LEN`, and walking `JpQuery` would couple this to
740			// jsonpath-rust's internal enum shape across versions. The tradeoff is a
741			// false positive on a query whose *string literal* contains `match(` — a
742			// recoverable 4xx. Walk the parsed AST instead if that ever bites.
743			for func in ["match(", "search("] {
744				if path.contains(func) {
745					return Err(Error::ValidationError(format!(
746						"JSONPath query '{path}' uses '{func})' — regex filter functions are \
747						 not supported, because they recompile the pattern at every node of \
748						 every document indexed"
749					)));
750				}
751			}
752			Selector::JsonPath(Box::new(query))
753		} else {
754			let segments = split_dotted(&path);
755			if segments.len() > MAX_PATH_SEGMENTS {
756				return Err(Error::ValidationError(format!(
757					"field path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
758					segments.len()
759				)));
760			}
761			Selector::Dotted(segments)
762		};
763
764		Ok(FieldRule {
765			selector,
766			mode,
767			keys,
768			exclude_keys,
769			prefix: prefix.unwrap_or_default(),
770			prefix_keys,
771			max_depth: max_depth.unwrap_or(DEFAULT_EXTRACT_DEPTH).clamp(1, MAX_EXTRACT_DEPTH),
772		})
773	}
774}
775
776#[cfg(test)]
777mod tests {
778	use super::*;
779
780	fn parse(json: &serde_json::Value) -> ClResult<IndexRules> {
781		IndexRules::parse(json)
782	}
783
784	/// The dotted segments of a rule's selector, or `None` if it is a JSONPath.
785	fn dotted(rule: &FieldRule) -> Option<&[String]> {
786		match &rule.selector {
787			Selector::Dotted(path) => Some(path),
788			Selector::JsonPath(_) => None,
789		}
790	}
791
792	#[test]
793	fn parses_the_notillo_shape() {
794		let rules = parse(&serde_json::json!({
795			"v": 1,
796			"parts": [
797				{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
798				{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
799				  "anchor": "docId", "order": ["o"],
800				  "prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
801				  "body": [
802					{ "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] },
803					{ "path": "$..tg", "extract": "string", "prefix": "#" },
804					"pr.caption"
805				  ] }
806			],
807			"limits": { "maxParts": 100, "maxBodyChars": 500 }
808		}))
809		.expect("parse");
810
811		assert_eq!(rules.parts.len(), 2);
812		assert_eq!(rules.limits.max_parts, 100);
813		assert_eq!(rules.limits.max_body_chars, 500);
814
815		let page = rules.owner_rule("p").expect("emitting page rule");
816		assert_eq!(dotted(&page.title[0]), Some(&["ti".to_owned()][..]));
817		assert_eq!(page.parent.as_deref(), Some("pp"));
818
819		let block = rules.parts.iter().find(|p| p.kind == "b").expect("block rule");
820		let attach = block.attach_to.as_ref().expect("attachTo");
821		assert_eq!((attach.kind.as_str(), attach.field.as_str()), ("p", "p"));
822		assert_eq!(block.prune, ["$..c[0:][1:]", "$..cells[0:][0:][1:]"]);
823		assert_eq!(block.body[0].keys, ["c", "cells", "wt"]);
824		assert_eq!(block.body[1].mode, ExtractMode::String);
825		assert_eq!(block.body[1].prefix, "#");
826		// A bare string entry is the same rule with defaults.
827		assert_eq!(dotted(&block.body[2]), Some(&["pr".to_owned(), "caption".to_owned()][..]));
828		assert!(block.body[2].keys.is_empty());
829		assert!(block.body[2].exclude_keys.is_empty());
830		assert_eq!(block.body[2].mode, ExtractMode::Text);
831	}
832
833	#[test]
834	fn accepts_field_as_an_alias_for_path() {
835		// The spelling every stored manifest uses, with the modifiers that predate
836		// `keys`.
837		let rules = parse(&serde_json::json!({
838			"parts": [{ "kind": "b", "body": [
839				{ "field": "c", "excludeKeys": ["l"], "prefixKeys": { "tg": "#" } }
840			] }]
841		}))
842		.expect("parse");
843		let block = rules.owner_rule("b").expect("block rule");
844		assert_eq!(dotted(&block.body[0]), Some(&["c".to_owned()][..]));
845		assert_eq!(block.body[0].exclude_keys, ["l"]);
846		assert_eq!(block.body[0].prefix_keys.get("tg").map(String::as_str), Some("#"));
847	}
848
849	#[test]
850	fn rejects_a_malformed_field_entry() {
851		let err = parse(&serde_json::json!({ "parts": [{ "kind": "p", "body": [42] }] }))
852			.expect_err("a number is not a field rule");
853		assert!(format!("{err}").contains("RawFullField"), "got {err}");
854		assert!(
855			parse(&serde_json::json!({
856				"parts": [{ "kind": "p", "body": [{ "extract": "text" }] }]
857			}))
858			.is_err(),
859			"a field rule with no path selects nothing and must be refused"
860		);
861	}
862
863	#[test]
864	fn caps_the_key_list_lengths() {
865		let many: Vec<String> = (0..100).map(|i| format!("k{i}")).collect();
866		for what in ["keys", "excludeKeys"] {
867			let err = parse(&serde_json::json!({
868				"parts": [{ "kind": "p", "body": [{ "path": "c", what: many }] }]
869			}));
870			assert!(err.is_err(), "{what} must be capped");
871		}
872	}
873
874	#[test]
875	fn compiles_a_jsonpath_field_and_rejects_a_malformed_one() {
876		let rules = parse(&serde_json::json!({
877			"parts": [{ "kind": "p", "body": [{ "field": "$.c[?@.t=='p'].text" }] }]
878		}))
879		.expect("parse");
880		let page = rules.owner_rule("p").expect("page rule");
881		assert!(dotted(&page.body[0]).is_none(), "a '$…' field must compile as JSONPath");
882
883		assert!(
884			parse(&serde_json::json!({
885				"parts": [{ "kind": "p", "body": [{ "field": "$.c[?" }] }]
886			}))
887			.is_err(),
888			"a malformed query must be refused at registration, not stored"
889		);
890		assert!(
891			parse(&serde_json::json!({
892				"parts": [{ "kind": "p", "body": [{ "field": format!("$.{}", "a".repeat(300)) }] }]
893			}))
894			.is_err(),
895			"an over-long query must be refused"
896		);
897	}
898
899	#[test]
900	fn rejects_a_prune_pattern_that_would_delete_the_whole_document() {
901		// Invisible from the manifest: `delete_by_path("$")` maps to
902		// `DeletionInfo::Root`, which replaces the document with `Value::Null` —
903		// every part of it would silently stop being indexed.
904		let err = parse(&serde_json::json!({
905			"parts": [{ "kind": "p", "prune": ["$"], "title": ["ti"] }]
906		}))
907		.expect_err("the bare root must be refused");
908		assert!(format!("{err}").contains("whole document"), "got {err}");
909	}
910
911	#[test]
912	fn rejects_a_prune_pattern_that_is_not_a_jsonpath_query() {
913		for pattern in ["c.0".to_owned(), "$..c[?".to_owned(), format!("$.{}", "a".repeat(300))] {
914			assert!(
915				parse(&serde_json::json!({
916					"parts": [{ "kind": "p", "prune": [pattern], "title": ["ti"] }]
917				}))
918				.is_err(),
919				"'{pattern}' must be refused at registration, not stored"
920			);
921		}
922	}
923
924	#[test]
925	fn caps_the_prune_list_length() {
926		let many: Vec<String> = (0..20).map(|i| format!("$..k{i}")).collect();
927		let err = parse(&serde_json::json!({
928			"parts": [{ "kind": "p", "prune": many, "title": ["ti"] }]
929		}))
930		.expect_err("the prune list must be capped");
931		assert!(format!("{err}").contains(&MAX_PRUNE_RULES.to_string()), "got {err}");
932	}
933
934	#[test]
935	fn caps_the_order_list_length() {
936		let many: Vec<String> = (0..=MAX_ORDER_FIELDS).map(|i| format!("k{i}")).collect();
937		let err = parse(&serde_json::json!({
938			"parts": [{ "kind": "p", "order": many, "title": ["ti"] }]
939		}))
940		.expect_err("the order list must be capped");
941		assert!(format!("{err}").contains(&MAX_ORDER_FIELDS.to_string()), "got {err}");
942	}
943
944	#[test]
945	fn caps_the_segment_count_of_order_anchor_and_parent() {
946		let deep = (0..=MAX_PATH_SEGMENTS).map(|i| format!("s{i}")).collect::<Vec<_>>().join(".");
947		for what in ["order", "anchor", "parent"] {
948			let value =
949				if what == "order" { serde_json::json!([deep]) } else { serde_json::json!(deep) };
950			let manifest = serde_json::json!({
951				"parts": [{ "kind": "p", what: value, "title": ["ti"] }]
952			});
953			let Err(err) = parse(&manifest) else {
954				panic!("an over-long {what} path must be refused");
955			};
956			assert!(format!("{err}").contains(&MAX_PATH_SEGMENTS.to_string()), "got {err}");
957		}
958	}
959
960	#[test]
961	fn accepts_doc_id_as_an_anchor() {
962		parse(&serde_json::json!({
963			"parts": [{ "kind": "p", "anchor": DOC_ID, "title": ["ti"] }]
964		}))
965		.expect("`docId` is a single segment and must stay accepted");
966	}
967
968	#[test]
969	fn rejects_a_jsonpath_regex_filter() {
970		// jsonpath-rust recompiles the pattern inside the per-node comparison, so
971		// one of these costs a `Regex::new` per node of every document indexed.
972		for path in ["$..[?match(@.t,'p')]", "$..[?search(@.t,'p')]"] {
973			let err = parse(&serde_json::json!({
974				"parts": [{ "kind": "p", "title": [path] }]
975			}))
976			.expect_err("a regex filter must be refused at registration");
977			assert!(format!("{err}").contains("regex filter functions"), "got {err}");
978		}
979		// An ordinary query is untouched.
980		parse(&serde_json::json!({
981			"parts": [{ "kind": "p", "title": ["$.blocks[*].content"] }]
982		}))
983		.expect("an ordinary JSONPath query must still be accepted");
984	}
985
986	#[test]
987	fn rejects_a_newer_manifest_version() {
988		let err = parse(&serde_json::json!({ "v": 99, "parts": [{ "kind": "p" }] }));
989		assert!(err.is_err());
990	}
991
992	#[test]
993	fn rejects_attach_to_a_non_emitting_part() {
994		let err = parse(&serde_json::json!({
995			"parts": [{ "kind": "b", "attachTo": { "kind": "p", "field": "p" } }]
996		}));
997		assert!(err.is_err(), "attaching to a part that emits no rows must fail");
998	}
999
1000	#[test]
1001	fn rejects_two_emitting_rules_for_one_kind() {
1002		let err = parse(&serde_json::json!({
1003			"parts": [{ "kind": "p", "title": ["a"] }, { "kind": "p", "title": ["b"] }]
1004		}));
1005		assert!(err.is_err());
1006	}
1007
1008	#[test]
1009	fn rejects_empty_and_unknown_shapes() {
1010		assert!(parse(&serde_json::json!({ "parts": [] })).is_err());
1011		assert!(parse(&serde_json::json!({ "parts": [{ "kind": "" }] })).is_err());
1012		assert!(
1013			parse(&serde_json::json!({
1014				"parts": [{ "kind": "p", "body": [{ "field": "c", "extract": "html" }] }]
1015			}))
1016			.is_err(),
1017			"unknown extract mode must not be silently ignored"
1018		);
1019		assert!(
1020			parse(&serde_json::json!({ "parts": [{ "kind": "p", "nope": 1 }] })).is_err(),
1021			"unknown manifest keys must be rejected, not dropped"
1022		);
1023		// A field rule is not an untagged enum any more precisely so that this
1024		// message can name the key instead of reporting "no variant matched".
1025		let err = parse(&serde_json::json!({
1026			"parts": [{ "kind": "p", "body": [{ "path": "c", "keyz": ["v"] }] }]
1027		}))
1028		.expect_err("an unknown field-rule key must be rejected");
1029		assert!(format!("{err}").contains("keyz"), "the error must name the offending key: {err}");
1030	}
1031
1032	#[test]
1033	fn clamps_absurd_limits_instead_of_failing() {
1034		let rules = parse(&serde_json::json!({
1035			"parts": [{ "kind": "p" }],
1036			"limits": { "maxParts": 99_999_999, "maxBodyChars": 0 }
1037		}))
1038		.expect("parse");
1039		assert_eq!(rules.limits.max_parts, DEFAULT_MAX_PARTS);
1040		assert_eq!(rules.limits.max_body_chars, 1);
1041	}
1042}
1043
1044// vim: ts=4