Skip to main content

alap/
lib.rs

1// Copyright 2026 Daniel Smith
2// Licensed under the Apache License, Version 2.0
3// See https://www.apache.org/licenses/LICENSE-2.0
4
5//! Rust port of the Alap expression parser.
6//!
7//! This is the server-side subset of `alap/core` (TypeScript).  It covers
8//! expression parsing, config merging, regex validation, and URL sanitization.
9//!
10//! # Grammar
11//!
12//! ```text
13//! query   = segment (',' segment)*
14//! segment = term (op term)* refiner*
15//! op      = '+' | '|' | '-'
16//! term    = '(' segment ')' | atom
17//! atom    = ITEM_ID | CLASS | DOM_REF | REGEX | PROTOCOL
18//! refiner = '*' name (':' arg)* '*'
19//! ```
20
21pub mod link_provenance;
22mod parser;
23mod sanitize;
24pub mod sanitize_by_tier;
25mod ssrf_guard;
26pub mod types;
27mod validate;
28mod validate_config;
29
30pub use parser::ExpressionParser;
31pub use sanitize::{
32    sanitize_url, sanitize_url_strict, sanitize_url_with_schemes, DEFAULT_SCHEMES, STRICT_SCHEMES,
33};
34pub use ssrf_guard::is_private_host;
35pub use types::{Config, Link, LinkWithId, Macro, Protocol, ProtocolHandler, RegexValidation, Tier};
36pub use validate::validate_regex;
37pub use validate_config::{
38    sanitize_link_urls, validate_config, validate_config_with_options, ValidateOptions,
39};
40
41use sanitize::sanitize_link;
42use std::collections::HashMap;
43
44/// Resolves an expression and returns matching links with sanitized URLs.
45#[must_use]
46pub fn resolve(config: &Config, expression: &str) -> Vec<LinkWithId> {
47    let mut parser = ExpressionParser::new(config);
48    let ids = parser.query(expression, "");
49    ids.iter()
50        .filter_map(|id| {
51            config.all_links.get(id).map(|link| LinkWithId {
52                id: id.clone(),
53                link: sanitize_link(link),
54            })
55        })
56        .collect()
57}
58
59/// Resolves an expression and returns a map of id → sanitized link.
60#[must_use]
61pub fn cherry_pick(config: &Config, expression: &str) -> HashMap<String, Link> {
62    let mut parser = ExpressionParser::new(config);
63    let ids = parser.query(expression, "");
64    ids.iter()
65        .filter_map(|id| {
66            config
67                .all_links
68                .get(id)
69                .map(|link| (id.clone(), sanitize_link(link)))
70        })
71        .collect()
72}
73
74/// Shallow-merges multiple configs. Later configs win on collision.
75#[must_use]
76pub fn merge_configs(configs: &[&Config]) -> Config {
77    const BLOCKED: &[&str] = &["__proto__", "constructor", "prototype"];
78
79    let mut merged = Config::default();
80
81    for cfg in configs {
82        for (k, v) in &cfg.settings {
83            if !BLOCKED.contains(&k.as_str()) {
84                merged.settings.insert(k.clone(), v.clone());
85            }
86        }
87        for (k, v) in &cfg.macros {
88            if !BLOCKED.contains(&k.as_str()) {
89                merged.macros.insert(k.clone(), v.clone());
90            }
91        }
92        for (k, v) in &cfg.all_links {
93            if !BLOCKED.contains(&k.as_str()) {
94                merged.all_links.insert(k.clone(), v.clone());
95            }
96        }
97        for (k, v) in &cfg.search_patterns {
98            if !BLOCKED.contains(&k.as_str()) {
99                merged.search_patterns.insert(k.clone(), v.clone());
100            }
101        }
102    }
103
104    merged
105}