Enforce the supported Liquid subset.

This commit is contained in:
2026-07-21 20:21:35 +02:00
parent 557497d4c2
commit 3ac7a8bfc4
6 changed files with 350 additions and 101 deletions

View File

@@ -10,7 +10,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, including publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search.
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. - Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.

View File

@@ -12,6 +12,19 @@ use crate::model::{DomainEntity, NotificationAction, Template, TemplateKind, Tem
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file}; use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
const ALLOWED_LIQUID_TAGS: &[&str] = &[
"if", "elsif", "else", "endif", "for", "endfor", "assign", "render",
];
const ALLOWED_LIQUID_FILTERS: &[&str] = &[
"escape",
"url_encode",
"default",
"append",
"i18n",
"markdown",
"slugify",
];
/// Create a new draft template. Content stored in DB, no file written. /// Create a new draft template. Content stored in DB, no file written.
pub fn create_template( pub fn create_template(
conn: &Connection, conn: &Connection,
@@ -136,11 +149,8 @@ pub fn save_template(
/// Validate Liquid template syntax. Returns Ok(()) or a parse error message. /// Validate Liquid template syntax. Returns Ok(()) or a parse error message.
pub fn validate_template(content: &str) -> Result<(), String> { pub fn validate_template(content: &str) -> Result<(), String> {
// Check for basic Liquid syntax correctness: validate_liquid_subset(content)?;
// Matching {% %} tags, matching {{ }} crate::render::validate_liquid_template_syntax(content)
validate_liquid_brackets(content)?;
validate_liquid_blocks(content)?;
Ok(())
} }
/// Publish a template: write frontmatter+body to file, clear content in DB. /// Publish a template: write frontmatter+body to file, clear content in DB.
@@ -295,112 +305,171 @@ fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
} }
} }
fn validate_liquid_brackets(content: &str) -> Result<(), String> { fn validate_liquid_subset(content: &str) -> Result<(), String> {
// Check balanced {{ }} and {% %} let mut cursor = 0;
let mut in_output = false; while let Some((offset, is_tag)) = next_liquid_markup(&content[cursor..]) {
let mut in_tag = false; let markup_start = cursor + offset + 2;
let chars: Vec<char> = content.chars().collect(); let close = if is_tag { "%}" } else { "}}" };
let len = chars.len(); let Some(close_offset) = find_unquoted(&content[markup_start..], close) else {
let mut i = 0; break;
};
let markup_end = markup_start + close_offset;
let markup = content[markup_start..markup_end]
.trim()
.trim_start_matches('-')
.trim_end_matches('-')
.trim();
while i < len { let tag = is_tag.then(|| identifier_at_start(markup)).flatten();
if i + 1 < len { if let Some(tag) = tag
let pair = (chars[i], chars[i + 1]); && !ALLOWED_LIQUID_TAGS.contains(&tag)
match pair { {
('{', '{') if !in_output && !in_tag => { return Err(format!("unsupported tag: {tag}"));
in_output = true;
i += 2;
continue;
}
('}', '}') if in_output => {
in_output = false;
i += 2;
continue;
}
('{', '%') if !in_output && !in_tag => {
in_tag = true;
i += 2;
continue;
}
('%', '}') if in_tag => {
in_tag = false;
i += 2;
continue;
}
_ => {}
}
} }
i += 1;
}
if in_output { validate_liquid_filters(markup)?;
return Err("unclosed {{ output tag".to_string()); if let Some(tag @ ("if" | "elsif")) = tag {
} validate_liquid_operators(markup[tag.len()..].trim_start())?;
if in_tag { }
return Err("unclosed {% tag".to_string()); cursor = markup_end + close.len();
} }
Ok(()) Ok(())
} }
fn validate_liquid_blocks(content: &str) -> Result<(), String> { fn next_liquid_markup(content: &str) -> Option<(usize, bool)> {
// Check that block tags are balanced (if/endif, for/endfor) match (content.find("{%"), content.find("{{")) {
let mut if_depth: i32 = 0; (Some(tag), Some(output)) if tag <= output => Some((tag, true)),
let mut for_depth: i32 = 0; (Some(_), Some(output)) => Some((output, false)),
(Some(tag), None) => Some((tag, true)),
(None, Some(output)) => Some((output, false)),
(None, None) => None,
}
}
// Simple regex-free scanner for {% tag %} blocks fn find_unquoted(content: &str, needle: &str) -> Option<usize> {
let chars: Vec<char> = content.chars().collect(); let bytes = content.as_bytes();
let len = chars.len(); let needle = needle.as_bytes();
let mut i = 0; let mut quote = None;
let mut escaped = false;
while i < len { let mut index = 0;
if i + 1 < len && chars[i] == '{' && chars[i + 1] == '%' { while index + needle.len() <= bytes.len() {
// Find closing %} let byte = bytes[index];
let start = i + 2; if let Some(active_quote) = quote {
if let Some(end_offset) = content[start..].find("%}") { if escaped {
let tag_content = content[start..start + end_offset].trim(); escaped = false;
let tag_content = tag_content } else if byte == b'\\' {
.trim_start_matches('-') escaped = true;
.trim_end_matches('-') } else if byte == active_quote {
.trim(); quote = None;
let first_word = tag_content.split_whitespace().next().unwrap_or("");
match first_word {
"if" => if_depth += 1,
"endif" => {
if_depth -= 1;
if if_depth < 0 {
return Err(
"unexpected {% endif %} without matching {% if %}".to_string()
);
}
}
"for" => for_depth += 1,
"endfor" => {
for_depth -= 1;
if for_depth < 0 {
return Err(
"unexpected {% endfor %} without matching {% for %}".to_string()
);
}
}
_ => {}
}
i = start + end_offset + 2;
continue;
} }
} else if matches!(byte, b'\'' | b'"') {
quote = Some(byte);
} else if &bytes[index..index + needle.len()] == needle {
return Some(index);
} }
i += 1; index += 1;
} }
None
}
if if_depth > 0 { fn identifier_at_start(content: &str) -> Option<&str> {
return Err(format!("{if_depth} unclosed {{% if %}} block(s)")); let end = content
} .find(|character: char| !is_liquid_identifier(character))
if for_depth > 0 { .unwrap_or(content.len());
return Err(format!("{for_depth} unclosed {{% for %}} block(s)")); (end > 0).then(|| &content[..end])
}
fn is_liquid_identifier(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}
fn validate_liquid_filters(markup: &str) -> Result<(), String> {
for pipe in unquoted_byte_positions(markup, b'|') {
let after_pipe = markup[pipe + 1..].trim_start();
if let Some(filter) = identifier_at_start(after_pipe)
&& !ALLOWED_LIQUID_FILTERS.contains(&filter)
{
return Err(format!("unsupported filter: {filter}"));
}
} }
Ok(()) Ok(())
} }
fn validate_liquid_operators(markup: &str) -> Result<(), String> {
let bytes = markup.as_bytes();
let mut quote = None;
let mut escaped = false;
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if let Some(active_quote) = quote {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == active_quote {
quote = None;
}
index += 1;
continue;
}
if matches!(byte, b'\'' | b'"') {
quote = Some(byte);
index += 1;
continue;
}
let operator = match (byte, bytes.get(index + 1).copied()) {
(b'!', Some(b'=')) => Some("!="),
(b'>', Some(b'=')) => Some(">="),
(b'<', Some(b'=')) => Some("<="),
(b'<', _) => Some("<"),
_ => None,
};
if let Some(operator) = operator {
return Err(format!("unsupported operator: {operator}"));
}
if bytes[index..].starts_with(b"contains")
&& is_word_boundary(bytes.get(index.wrapping_sub(1)).copied())
&& is_word_boundary(bytes.get(index + "contains".len()).copied())
&& !markup[..index].trim_end().is_empty()
&& !markup[index + "contains".len()..].trim_start().is_empty()
&& !markup[..index].trim_end().ends_with('.')
{
return Err("unsupported operator: contains".to_string());
}
index += 1;
}
Ok(())
}
fn is_word_boundary(byte: Option<u8>) -> bool {
byte.is_none_or(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-'))
}
fn unquoted_byte_positions(content: &str, target: u8) -> Vec<usize> {
let mut positions = Vec::new();
let mut quote = None;
let mut escaped = false;
for (index, byte) in content.bytes().enumerate() {
if let Some(active_quote) = quote {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == active_quote {
quote = None;
}
} else if matches!(byte, b'\'' | b'"') {
quote = Some(byte);
} else if byte == target {
positions.push(index);
}
}
positions
}
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> { fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
let count: i64 = conn.with(|c| { let count: i64 = conn.with(|c| {
posts::table posts::table
@@ -584,6 +653,141 @@ mod tests {
assert!(validate_template("{% for p in posts %}{{ p.title }}{% endfor %}").is_ok()); assert!(validate_template("{% for p in posts %}{{ p.title }}{% endfor %}").is_ok());
} }
#[test]
fn validate_rejects_unsupported_liquid_tags() {
for tag in [
"unless",
"case",
"capture",
"tablerow",
"cycle",
"increment",
] {
let content = format!("{{% {tag} foo %}}bar{{% end{tag} %}}");
assert_eq!(
validate_template(&content),
Err(format!("unsupported tag: {tag}"))
);
}
}
#[test]
fn validate_rejects_unsupported_liquid_filters() {
for filter in ["upcase", "downcase", "date", "truncate", "split", "reverse"] {
let content = format!("{{{{ title | {filter} }}}}");
assert_eq!(
validate_template(&content),
Err(format!("unsupported filter: {filter}"))
);
}
}
#[test]
fn validate_rejects_unsupported_liquid_operators() {
for operator in ["!=", "<", ">=", "<=", "contains"] {
let content = format!("{{% if title {operator} other %}}yes{{% endif %}}");
assert_eq!(
validate_template(&content),
Err(format!("unsupported operator: {operator}"))
);
}
}
#[test]
fn validate_allows_the_complete_liquid_subset() {
for filter in [
"escape",
"url_encode",
"default: fallback",
"append: suffix",
"i18n: language",
"markdown: post.id, posts, post_paths, media_paths, language, prefix",
"slugify",
] {
let result = validate_template(&format!("{{{{ title | {filter} }}}}"));
assert!(
result.is_ok(),
"supported filter {filter} was rejected: {result:?}"
);
}
for content in [
"{% if title == other %}yes{% elsif total > 0 %}more{% else %}no{% endif %}",
"{% if published %}yes{% endif %}",
"{% if a == b and c > d %}yes{% endif %}",
"{% if a == b or c == blank %}yes{% endif %}",
"{% assign href = '/posts/' | append: post.slug %}",
"{% render 'partials/card', post: post %}",
"{%- for post in posts -%}{{- post.title | escape -}}{%- endfor -%}",
"{% if values.size > 0 and map[key] %}yes{% endif %}",
] {
let result = validate_template(content);
assert!(
result.is_ok(),
"supported syntax was rejected: {content}: {result:?}"
);
}
}
#[test]
fn bundled_starter_templates_conform_to_the_published_liquid_subset() {
for (name, content) in [
(
"single-post",
include_str!("../../../../assets/starter-templates/single-post.liquid"),
),
(
"post-list",
include_str!("../../../../assets/starter-templates/post-list.liquid"),
),
(
"not-found",
include_str!("../../../../assets/starter-templates/not-found.liquid"),
),
(
"head",
include_str!("../../../../assets/starter-templates/partials/head.liquid"),
),
(
"language-switcher",
include_str!(
"../../../../assets/starter-templates/partials/language-switcher.liquid"
),
),
(
"menu-items",
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"),
),
(
"menu",
include_str!("../../../../assets/starter-templates/partials/menu.liquid"),
),
] {
let result = validate_template(content);
assert!(result.is_ok(), "starter template {name}: {result:?}");
}
}
#[test]
fn publish_rejects_unsupported_filter_and_leaves_template_draft() {
let (db, dir) = setup();
let template = create_template(
db.conn(),
"p1",
"Strict",
TemplateKind::Post,
"{{ title | upcase }}",
)
.unwrap();
let error = publish_template(db.conn(), dir.path(), &template.id).unwrap_err();
assert!(error.to_string().contains("unsupported filter: upcase"));
let reloaded = qt::get_template_by_id(db.conn(), &template.id).unwrap();
assert_eq!(reloaded.status, TemplateStatus::Draft);
assert_eq!(reloaded.content.as_deref(), Some("{{ title | upcase }}"));
assert!(!dir.path().join("templates/strict.liquid").exists());
}
#[test] #[test]
fn validate_unclosed_output() { fn validate_unclosed_output() {
assert!(validate_template("<div>{{ title </div>").is_err()); assert!(validate_template("<div>{{ title </div>").is_err());

View File

@@ -11,8 +11,8 @@ pub use generation::{
write_generated_bytes, write_generated_file, write_generated_bytes, write_generated_file,
}; };
pub use markdown::render_markdown_to_html; pub use markdown::render_markdown_to_html;
pub(crate) use page_renderer::render_liquid_template_with_host;
pub use page_renderer::{RenderError, render_liquid_template}; pub use page_renderer::{RenderError, render_liquid_template};
pub(crate) use page_renderer::{render_liquid_template_with_host, validate_liquid_template_syntax};
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant}; pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
pub use routes::{ pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page, RenderedPage, build_canonical_post_path, render_starter_list_page,

View File

@@ -56,10 +56,7 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
compiled_partials.add(format!("{name}.liquid"), content.clone()); compiled_partials.add(format!("{name}.liquid"), content.clone());
} }
let parser = ParserBuilder::with_stdlib() let parser = liquid_parser_builder(host)
.filter(I18n)
.filter(Markdown { host })
.filter(Slugify)
.partials(compiled_partials) .partials(compiled_partials)
.build()?; .build()?;
let template = parser.parse(template_source)?; let template = parser.parse(template_source)?;
@@ -67,6 +64,23 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
Ok(template.render(&globals)?) Ok(template.render(&globals)?)
} }
pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(), String> {
let parser = liquid_parser_builder(Arc::new(UnavailableHost))
.build()
.map_err(|error| error.to_string())?;
parser
.parse(template_source)
.map(|_| ())
.map_err(|error| error.to_string())
}
fn liquid_parser_builder(host: Arc<dyn HostApi>) -> ParserBuilder {
ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown { host })
.filter(Slugify)
}
#[derive(Clone, ParseFilter, FilterReflection)] #[derive(Clone, ParseFilter, FilterReflection)]
#[filter( #[filter(
name = "slugify", name = "slugify",

View File

@@ -2278,6 +2278,7 @@ mod tests {
post = bds.posts.get(post.id).title, post = bds.posts.get(post.id).title,
script = script.kind, script = script.kind,
template = template.kind, template = template.kind,
template_validation = bds.templates.validate("{{ title | upcase }}"),
tag = tag.name, tag = tag.name,
media = media.title, media = media.title,
category = metadata.categories[1], category = metadata.categories[1],
@@ -2304,6 +2305,11 @@ mod tests {
assert_eq!(result.value["post"], "Lua post"); assert_eq!(result.value["post"], "Lua post");
assert_eq!(result.value["script"], "utility"); assert_eq!(result.value["script"], "utility");
assert_eq!(result.value["template"], "post"); assert_eq!(result.value["template"], "post");
assert_eq!(result.value["template_validation"]["valid"], false);
assert_eq!(
result.value["template_validation"]["errors"][0],
"unsupported filter: upcase"
);
assert_eq!(result.value["tag"], "lua"); assert_eq!(result.value["tag"], "lua");
assert_eq!(result.value["media"], "Close"); assert_eq!(result.value["media"], "Close");
assert!(result.value["foreign"].is_null()); assert!(result.value["foreign"].is_null());

View File

@@ -489,6 +489,31 @@ fn every_write_tool_is_inert_then_approves_or_rejects_through_shared_engines() {
); );
} }
#[test]
fn propose_template_rejects_unsupported_liquid_without_creating_a_proposal() {
let fixture = Fixture::new();
let context = fixture.context();
let error = context
.call_tool(
"propose_template",
json!({
"title": "Unsafe template",
"kind": "partial",
"content": "{{ title | upcase }}"
}),
)
.unwrap_err();
assert!(error.to_string().contains("unsupported filter: upcase"));
let db = fixture.database();
assert!(
mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.is_empty()
);
}
#[test] #[test]
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() { fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
let fixture = Fixture::new(); let fixture = Fixture::new();