use std::collections::HashMap;
use serde::Deserialize;
use crate::prelude::*;
pub const DOC_ID: &str = "docId";
pub const SUPPORTED_VERSION: u32 = 1;
const MAX_PART_RULES: usize = 32;
const MAX_FIELD_RULES: usize = 32;
const MAX_KEY_RULES: usize = 64;
const MAX_PRUNE_RULES: usize = 8;
const MAX_ORDER_FIELDS: usize = 8;
const MAX_PATH_SEGMENTS: usize = 8;
const MAX_EXTRACT_DEPTH: usize = 32;
const MAX_JSONPATH_LEN: usize = 256;
pub(crate) const MAX_JSONPATH_NODES: usize = 65_536;
const DEFAULT_MAX_PARTS: usize = 5000;
const DEFAULT_MAX_BODY_CHARS: usize = 32_000;
const DEFAULT_MAX_TOTAL_CHARS: usize = 512_000;
const DEFAULT_EXTRACT_DEPTH: usize = 16;
#[derive(Debug, Clone)]
pub struct IndexRules {
pub parts: Vec<PartRule>,
pub limits: Limits,
}
#[derive(Debug, Clone)]
pub struct PartRule {
pub kind: String,
pub attach_to: Option<AttachTo>,
pub anchor: Option<String>,
pub order: Vec<String>,
pub parent: Option<String>,
pub prune: Vec<String>,
pub title: Vec<FieldRule>,
pub body: Vec<FieldRule>,
pub tags: Vec<FieldRule>,
}
#[derive(Debug, Clone)]
pub struct AttachTo {
pub kind: String,
pub field: String,
}
#[derive(Debug, Clone)]
pub enum Selector {
Dotted(Vec<String>),
JsonPath(Box<jsonpath_rust::parser::model::JpQuery>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExtractMode {
#[default]
Text,
String,
}
#[derive(Debug, Clone)]
pub struct FieldRule {
pub selector: Selector,
pub mode: ExtractMode,
pub keys: Vec<String>,
pub exclude_keys: Vec<String>,
pub prefix: String,
pub prefix_keys: HashMap<String, String>,
pub max_depth: usize,
}
impl FieldRule {
pub fn dotted(field: &str) -> Self {
Self {
selector: Selector::Dotted(split_dotted(field)),
mode: ExtractMode::Text,
keys: Vec::new(),
exclude_keys: Vec::new(),
prefix: String::new(),
prefix_keys: HashMap::new(),
max_depth: DEFAULT_EXTRACT_DEPTH,
}
}
}
fn split_dotted(field: &str) -> Vec<String> {
field.split('.').filter(|s| !s.is_empty()).map(ToOwned::to_owned).collect()
}
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_parts: usize,
pub max_body_chars: usize,
pub max_total_chars: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_parts: DEFAULT_MAX_PARTS,
max_body_chars: DEFAULT_MAX_BODY_CHARS,
max_total_chars: DEFAULT_MAX_TOTAL_CHARS,
}
}
}
impl IndexRules {
pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
let raw: RawRules = serde_json::from_value(value.clone())
.map_err(|e| Error::ValidationError(format!("invalid search manifest: {e}")))?;
raw.validate()
}
pub fn owner_rule(&self, kind: &str) -> Option<&PartRule> {
self.parts.iter().find(|p| p.kind == kind && p.attach_to.is_none())
}
}
#[derive(Debug, Clone, Default)]
pub struct ActionSearchRules {
pub title: Vec<FieldRule>,
pub body: Vec<FieldRule>,
pub tags: Vec<FieldRule>,
}
impl ActionSearchRules {
pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
let raw: RawActionRules = serde_json::from_value(value.clone())
.map_err(|e| Error::ValidationError(format!("invalid action search manifest: {e}")))?;
raw.validate()
}
pub fn is_empty(&self) -> bool {
self.title.is_empty() && self.body.is_empty() && self.tags.is_empty()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawActionRules {
#[serde(default = "default_version")]
v: u32,
#[serde(default)]
title: Vec<RawField>,
#[serde(default)]
body: Vec<RawField>,
#[serde(default)]
tags: Vec<RawField>,
}
impl RawActionRules {
fn validate(self) -> ClResult<ActionSearchRules> {
if self.v > SUPPORTED_VERSION {
return Err(Error::ValidationError(format!(
"action search manifest version {} is newer than supported version \
{SUPPORTED_VERSION}",
self.v
)));
}
let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
if raw.len() > MAX_FIELD_RULES {
return Err(Error::ValidationError(format!(
"action search manifest has {} {what} rules, max {MAX_FIELD_RULES}",
raw.len()
)));
}
raw.into_iter().map(RawField::validate).collect()
};
let rules = ActionSearchRules {
title: fields(self.title, "title")?,
body: fields(self.body, "body")?,
tags: fields(self.tags, "tags")?,
};
if rules.is_empty() {
return Err(Error::ValidationError(
"action search manifest selects no fields; omit it instead".into(),
));
}
Ok(rules)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawRules {
#[serde(default = "default_version")]
v: u32,
#[serde(default)]
parts: Vec<RawPart>,
#[serde(default)]
limits: Option<RawLimits>,
}
fn default_version() -> u32 {
SUPPORTED_VERSION
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawPart {
kind: String,
#[serde(default)]
attach_to: Option<RawAttachTo>,
#[serde(default)]
anchor: Option<String>,
#[serde(default)]
order: Vec<String>,
#[serde(default)]
parent: Option<String>,
#[serde(default)]
prune: Vec<String>,
#[serde(default)]
title: Vec<RawField>,
#[serde(default)]
body: Vec<RawField>,
#[serde(default)]
tags: Vec<RawField>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawAttachTo {
kind: String,
field: String,
}
#[derive(Debug)]
pub(crate) enum RawField {
Path(String),
Full(RawFullField),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct RawFullField {
#[serde(alias = "field")]
path: String,
#[serde(default)]
extract: Option<String>,
#[serde(default)]
keys: Vec<String>,
#[serde(default)]
exclude_keys: Vec<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
prefix_keys: HashMap<String, String>,
#[serde(default)]
max_depth: Option<usize>,
}
impl<'de> Deserialize<'de> for RawField {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
match serde_json::Value::deserialize(de)? {
serde_json::Value::String(path) => Ok(Self::Path(path)),
other => serde_json::from_value(other).map(Self::Full).map_err(D::Error::custom),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[allow(clippy::struct_field_names)]
struct RawLimits {
#[serde(default)]
max_parts: Option<usize>,
#[serde(default)]
max_body_chars: Option<usize>,
#[serde(default)]
max_total_chars: Option<usize>,
}
impl RawRules {
fn validate(self) -> ClResult<IndexRules> {
if self.v > SUPPORTED_VERSION {
return Err(Error::ValidationError(format!(
"search manifest version {} is newer than supported version {SUPPORTED_VERSION}",
self.v
)));
}
if self.parts.is_empty() {
return Err(Error::ValidationError("search manifest has no parts".into()));
}
if self.parts.len() > MAX_PART_RULES {
return Err(Error::ValidationError(format!(
"search manifest has {} part rules, max {MAX_PART_RULES}",
self.parts.len()
)));
}
let parts = self.parts.into_iter().map(RawPart::validate).collect::<ClResult<Vec<_>>>()?;
for part in &parts {
let Some(attach) = &part.attach_to else { continue };
let owner_exists = parts.iter().any(|p| p.kind == attach.kind && p.attach_to.is_none());
if !owner_exists {
return Err(Error::ValidationError(format!(
"part '{}' attaches to '{}', which is not an emitting part",
part.kind, attach.kind
)));
}
}
let mut emitting: Vec<&str> = parts
.iter()
.filter(|p| p.attach_to.is_none())
.map(|p| p.kind.as_str())
.collect();
emitting.sort_unstable();
if emitting.windows(2).any(|w| w[0] == w[1]) {
return Err(Error::ValidationError(
"search manifest has two emitting rules for the same kind".into(),
));
}
let defaults = Limits::default();
let limits = self.limits.map_or(defaults, |l| Limits {
max_parts: l.max_parts.unwrap_or(defaults.max_parts).clamp(1, DEFAULT_MAX_PARTS),
max_body_chars: l
.max_body_chars
.unwrap_or(defaults.max_body_chars)
.clamp(1, DEFAULT_MAX_BODY_CHARS),
max_total_chars: l
.max_total_chars
.unwrap_or(defaults.max_total_chars)
.clamp(1, DEFAULT_MAX_TOTAL_CHARS),
});
Ok(IndexRules { parts, limits })
}
}
impl RawPart {
fn validate(self) -> ClResult<PartRule> {
if self.kind.is_empty() {
return Err(Error::ValidationError("part rule has an empty kind".into()));
}
if self.prune.len() > MAX_PRUNE_RULES {
return Err(Error::ValidationError(format!(
"part '{}' has {} prune patterns, max {MAX_PRUNE_RULES}",
self.kind,
self.prune.len()
)));
}
for pattern in &self.prune {
validate_prune(pattern)?;
}
if self.order.len() > MAX_ORDER_FIELDS {
return Err(Error::ValidationError(format!(
"part '{}' has {} order fields, max {MAX_ORDER_FIELDS}",
self.kind,
self.order.len()
)));
}
for (what, path) in self
.order
.iter()
.map(|p| ("order", p))
.chain(self.anchor.iter().map(|p| ("anchor", p)))
.chain(self.parent.iter().map(|p| ("parent", p)))
{
if path.is_empty() {
return Err(Error::ValidationError(format!(
"part '{}' has an empty {what} path",
self.kind
)));
}
let segments = split_dotted(path);
if segments.len() > MAX_PATH_SEGMENTS {
return Err(Error::ValidationError(format!(
"part '{}' {what} path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
self.kind,
segments.len()
)));
}
}
let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
if raw.len() > MAX_FIELD_RULES {
return Err(Error::ValidationError(format!(
"part '{}' has {} {what} rules, max {MAX_FIELD_RULES}",
self.kind,
raw.len()
)));
}
raw.into_iter().map(RawField::validate).collect()
};
Ok(PartRule {
attach_to: self.attach_to.map(|a| AttachTo { kind: a.kind, field: a.field }),
anchor: self.anchor,
order: self.order,
parent: self.parent,
prune: self.prune,
title: fields(self.title, "title")?,
body: fields(self.body, "body")?,
tags: fields(self.tags, "tags")?,
kind: self.kind,
})
}
}
fn validate_prune(pattern: &str) -> ClResult<()> {
if !pattern.starts_with('$') {
return Err(Error::ValidationError(format!(
"prune pattern '{pattern}' must be a JSONPath query starting with '$'"
)));
}
if pattern == "$" {
return Err(Error::ValidationError(
"prune pattern '$' would delete the whole document".into(),
));
}
if pattern.len() > MAX_JSONPATH_LEN {
return Err(Error::ValidationError(format!(
"prune pattern is {} chars, max {MAX_JSONPATH_LEN}",
pattern.len()
)));
}
jsonpath_rust::parser::parse_json_path(pattern)
.map_err(|e| Error::ValidationError(format!("invalid prune pattern '{pattern}': {e}")))?;
Ok(())
}
impl RawField {
pub(crate) fn validate(self) -> ClResult<FieldRule> {
let RawFullField { path, extract, keys, exclude_keys, prefix, prefix_keys, max_depth } =
match self {
Self::Path(path) => RawFullField {
path,
extract: None,
keys: Vec::new(),
exclude_keys: Vec::new(),
prefix: None,
prefix_keys: HashMap::new(),
max_depth: None,
},
Self::Full(full) => full,
};
let mode = match extract.as_deref() {
None | Some("text") => ExtractMode::Text,
Some("string") => ExtractMode::String,
Some(mode) => {
return Err(Error::ValidationError(format!("unknown extract mode '{mode}'")));
}
};
let cap = |n: usize, what: &str| -> ClResult<()> {
if n > MAX_KEY_RULES {
return Err(Error::ValidationError(format!(
"field '{path}' has {n} {what} entries, max {MAX_KEY_RULES}"
)));
}
Ok(())
};
cap(keys.len(), "keys")?;
cap(exclude_keys.len(), "excludeKeys")?;
cap(prefix_keys.len(), "prefixKeys")?;
let selector = if path.starts_with('$') {
if path.len() > MAX_JSONPATH_LEN {
return Err(Error::ValidationError(format!(
"JSONPath query is {} chars, max {MAX_JSONPATH_LEN}",
path.len()
)));
}
let query = jsonpath_rust::parser::parse_json_path(&path).map_err(|e| {
Error::ValidationError(format!("invalid JSONPath query '{path}': {e}"))
})?;
for func in ["match(", "search("] {
if path.contains(func) {
return Err(Error::ValidationError(format!(
"JSONPath query '{path}' uses '{func})' — regex filter functions are \
not supported, because they recompile the pattern at every node of \
every document indexed"
)));
}
}
Selector::JsonPath(Box::new(query))
} else {
let segments = split_dotted(&path);
if segments.len() > MAX_PATH_SEGMENTS {
return Err(Error::ValidationError(format!(
"field path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
segments.len()
)));
}
Selector::Dotted(segments)
};
Ok(FieldRule {
selector,
mode,
keys,
exclude_keys,
prefix: prefix.unwrap_or_default(),
prefix_keys,
max_depth: max_depth.unwrap_or(DEFAULT_EXTRACT_DEPTH).clamp(1, MAX_EXTRACT_DEPTH),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &serde_json::Value) -> ClResult<IndexRules> {
IndexRules::parse(json)
}
fn dotted(rule: &FieldRule) -> Option<&[String]> {
match &rule.selector {
Selector::Dotted(path) => Some(path),
Selector::JsonPath(_) => None,
}
}
#[test]
fn parses_the_notillo_shape() {
let rules = parse(&serde_json::json!({
"v": 1,
"parts": [
{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
"anchor": "docId", "order": ["o"],
"prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
"body": [
{ "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] },
{ "path": "$..tg", "extract": "string", "prefix": "#" },
"pr.caption"
] }
],
"limits": { "maxParts": 100, "maxBodyChars": 500 }
}))
.expect("parse");
assert_eq!(rules.parts.len(), 2);
assert_eq!(rules.limits.max_parts, 100);
assert_eq!(rules.limits.max_body_chars, 500);
let page = rules.owner_rule("p").expect("emitting page rule");
assert_eq!(dotted(&page.title[0]), Some(&["ti".to_owned()][..]));
assert_eq!(page.parent.as_deref(), Some("pp"));
let block = rules.parts.iter().find(|p| p.kind == "b").expect("block rule");
let attach = block.attach_to.as_ref().expect("attachTo");
assert_eq!((attach.kind.as_str(), attach.field.as_str()), ("p", "p"));
assert_eq!(block.prune, ["$..c[0:][1:]", "$..cells[0:][0:][1:]"]);
assert_eq!(block.body[0].keys, ["c", "cells", "wt"]);
assert_eq!(block.body[1].mode, ExtractMode::String);
assert_eq!(block.body[1].prefix, "#");
assert_eq!(dotted(&block.body[2]), Some(&["pr".to_owned(), "caption".to_owned()][..]));
assert!(block.body[2].keys.is_empty());
assert!(block.body[2].exclude_keys.is_empty());
assert_eq!(block.body[2].mode, ExtractMode::Text);
}
#[test]
fn accepts_field_as_an_alias_for_path() {
let rules = parse(&serde_json::json!({
"parts": [{ "kind": "b", "body": [
{ "field": "c", "excludeKeys": ["l"], "prefixKeys": { "tg": "#" } }
] }]
}))
.expect("parse");
let block = rules.owner_rule("b").expect("block rule");
assert_eq!(dotted(&block.body[0]), Some(&["c".to_owned()][..]));
assert_eq!(block.body[0].exclude_keys, ["l"]);
assert_eq!(block.body[0].prefix_keys.get("tg").map(String::as_str), Some("#"));
}
#[test]
fn rejects_a_malformed_field_entry() {
let err = parse(&serde_json::json!({ "parts": [{ "kind": "p", "body": [42] }] }))
.expect_err("a number is not a field rule");
assert!(format!("{err}").contains("RawFullField"), "got {err}");
assert!(
parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "extract": "text" }] }]
}))
.is_err(),
"a field rule with no path selects nothing and must be refused"
);
}
#[test]
fn caps_the_key_list_lengths() {
let many: Vec<String> = (0..100).map(|i| format!("k{i}")).collect();
for what in ["keys", "excludeKeys"] {
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "path": "c", what: many }] }]
}));
assert!(err.is_err(), "{what} must be capped");
}
}
#[test]
fn compiles_a_jsonpath_field_and_rejects_a_malformed_one() {
let rules = parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "field": "$.c[?@.t=='p'].text" }] }]
}))
.expect("parse");
let page = rules.owner_rule("p").expect("page rule");
assert!(dotted(&page.body[0]).is_none(), "a '$…' field must compile as JSONPath");
assert!(
parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "field": "$.c[?" }] }]
}))
.is_err(),
"a malformed query must be refused at registration, not stored"
);
assert!(
parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "field": format!("$.{}", "a".repeat(300)) }] }]
}))
.is_err(),
"an over-long query must be refused"
);
}
#[test]
fn rejects_a_prune_pattern_that_would_delete_the_whole_document() {
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "prune": ["$"], "title": ["ti"] }]
}))
.expect_err("the bare root must be refused");
assert!(format!("{err}").contains("whole document"), "got {err}");
}
#[test]
fn rejects_a_prune_pattern_that_is_not_a_jsonpath_query() {
for pattern in ["c.0".to_owned(), "$..c[?".to_owned(), format!("$.{}", "a".repeat(300))] {
assert!(
parse(&serde_json::json!({
"parts": [{ "kind": "p", "prune": [pattern], "title": ["ti"] }]
}))
.is_err(),
"'{pattern}' must be refused at registration, not stored"
);
}
}
#[test]
fn caps_the_prune_list_length() {
let many: Vec<String> = (0..20).map(|i| format!("$..k{i}")).collect();
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "prune": many, "title": ["ti"] }]
}))
.expect_err("the prune list must be capped");
assert!(format!("{err}").contains(&MAX_PRUNE_RULES.to_string()), "got {err}");
}
#[test]
fn caps_the_order_list_length() {
let many: Vec<String> = (0..=MAX_ORDER_FIELDS).map(|i| format!("k{i}")).collect();
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "order": many, "title": ["ti"] }]
}))
.expect_err("the order list must be capped");
assert!(format!("{err}").contains(&MAX_ORDER_FIELDS.to_string()), "got {err}");
}
#[test]
fn caps_the_segment_count_of_order_anchor_and_parent() {
let deep = (0..=MAX_PATH_SEGMENTS).map(|i| format!("s{i}")).collect::<Vec<_>>().join(".");
for what in ["order", "anchor", "parent"] {
let value =
if what == "order" { serde_json::json!([deep]) } else { serde_json::json!(deep) };
let manifest = serde_json::json!({
"parts": [{ "kind": "p", what: value, "title": ["ti"] }]
});
let Err(err) = parse(&manifest) else {
panic!("an over-long {what} path must be refused");
};
assert!(format!("{err}").contains(&MAX_PATH_SEGMENTS.to_string()), "got {err}");
}
}
#[test]
fn accepts_doc_id_as_an_anchor() {
parse(&serde_json::json!({
"parts": [{ "kind": "p", "anchor": DOC_ID, "title": ["ti"] }]
}))
.expect("`docId` is a single segment and must stay accepted");
}
#[test]
fn rejects_a_jsonpath_regex_filter() {
for path in ["$..[?match(@.t,'p')]", "$..[?search(@.t,'p')]"] {
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "title": [path] }]
}))
.expect_err("a regex filter must be refused at registration");
assert!(format!("{err}").contains("regex filter functions"), "got {err}");
}
parse(&serde_json::json!({
"parts": [{ "kind": "p", "title": ["$.blocks[*].content"] }]
}))
.expect("an ordinary JSONPath query must still be accepted");
}
#[test]
fn rejects_a_newer_manifest_version() {
let err = parse(&serde_json::json!({ "v": 99, "parts": [{ "kind": "p" }] }));
assert!(err.is_err());
}
#[test]
fn rejects_attach_to_a_non_emitting_part() {
let err = parse(&serde_json::json!({
"parts": [{ "kind": "b", "attachTo": { "kind": "p", "field": "p" } }]
}));
assert!(err.is_err(), "attaching to a part that emits no rows must fail");
}
#[test]
fn rejects_two_emitting_rules_for_one_kind() {
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "title": ["a"] }, { "kind": "p", "title": ["b"] }]
}));
assert!(err.is_err());
}
#[test]
fn rejects_empty_and_unknown_shapes() {
assert!(parse(&serde_json::json!({ "parts": [] })).is_err());
assert!(parse(&serde_json::json!({ "parts": [{ "kind": "" }] })).is_err());
assert!(
parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "field": "c", "extract": "html" }] }]
}))
.is_err(),
"unknown extract mode must not be silently ignored"
);
assert!(
parse(&serde_json::json!({ "parts": [{ "kind": "p", "nope": 1 }] })).is_err(),
"unknown manifest keys must be rejected, not dropped"
);
let err = parse(&serde_json::json!({
"parts": [{ "kind": "p", "body": [{ "path": "c", "keyz": ["v"] }] }]
}))
.expect_err("an unknown field-rule key must be rejected");
assert!(format!("{err}").contains("keyz"), "the error must name the offending key: {err}");
}
#[test]
fn clamps_absurd_limits_instead_of_failing() {
let rules = parse(&serde_json::json!({
"parts": [{ "kind": "p" }],
"limits": { "maxParts": 99_999_999, "maxBodyChars": 0 }
}))
.expect("parse");
assert_eq!(rules.limits.max_parts, DEFAULT_MAX_PARTS);
assert_eq!(rules.limits.max_body_chars, 1);
}
}