use super::block_provenance::{BlockProvenance, BlockProvenanceMap};
use super::{Block, EngineDocument};
use crate::engine::EngineInput;
use std::collections::HashSet;
#[derive(Clone, Debug)]
pub struct Fetched {
pub content_type: Option<String>,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransclusionPolicy {
pub enabled: bool,
pub allowed_schemes: Vec<String>,
pub max_depth: u8,
}
impl TransclusionPolicy {
pub fn deny_all() -> Self {
Self {
enabled: false,
allowed_schemes: Vec::new(),
max_depth: 0,
}
}
pub fn for_own_notes(allowed_schemes: Vec<String>, max_depth: u8) -> Self {
Self {
enabled: true,
allowed_schemes,
max_depth,
}
}
}
#[derive(Debug, Default)]
pub struct TranscludeOutcome {
pub resolved: usize,
pub denied: Vec<(String, String)>,
pub failed: Vec<(String, String)>,
pub provenance: BlockProvenanceMap,
}
pub fn parse_include(language: &str) -> Option<&str> {
let mut tokens = language.split_whitespace();
if tokens.next()? != "include" {
return None;
}
tokens.next()
}
fn scheme_of(url: &str) -> Option<&str> {
url.split_once("://").map(|(scheme, _)| scheme)
}
pub fn resolve_transclusions(
document: &mut EngineDocument,
fetch: &mut dyn FnMut(&str) -> Result<Fetched, String>,
render: &mut dyn FnMut(&EngineInput) -> Result<EngineDocument, String>,
policy: &TransclusionPolicy,
) -> TranscludeOutcome {
let mut outcome = TranscludeOutcome::default();
let mut visited: HashSet<String> = HashSet::new();
let passes = policy.max_depth.max(1);
for _pass in 0..passes {
let mut any_resolved = false;
let old_blocks = std::mem::take(&mut document.blocks);
let old_provenance = std::mem::replace(&mut outcome.provenance, BlockProvenanceMap::new());
let mut blocks: Vec<Block> = Vec::with_capacity(old_blocks.len());
for (old_index, block) in old_blocks.into_iter().enumerate() {
let new_index = blocks.len();
let carried = old_provenance.get(old_index).cloned();
let include_url = match &block {
Block::CodeBlock {
language: Some(language),
..
} => parse_include(language).map(str::to_string),
_ => None,
};
let keep = |block: Block, blocks: &mut Vec<Block>, outcome: &mut TranscludeOutcome| {
if let Some(p) = carried.clone() {
outcome.provenance.insert(new_index, p);
}
blocks.push(block);
};
let Some(url) = include_url else {
keep(block, &mut blocks, &mut outcome);
continue;
};
if !policy.enabled {
outcome.denied.push((url, "transclusion disabled".into()));
keep(block, &mut blocks, &mut outcome);
continue;
}
let scheme_allowed = scheme_of(&url)
.map(|s| policy.allowed_schemes.iter().any(|a| a == s))
.unwrap_or(false);
if !scheme_allowed {
outcome
.denied
.push((url, "scheme not in the allowlist".into()));
keep(block, &mut blocks, &mut outcome);
continue;
}
if !visited.insert(url.clone()) {
outcome
.denied
.push((url, "already resolved (cycle guard)".into()));
keep(block, &mut blocks, &mut outcome);
continue;
}
let fetched = match fetch(&url) {
Ok(fetched) => fetched,
Err(error) => {
outcome.failed.push((url, error));
keep(block, &mut blocks, &mut outcome);
continue;
}
};
let mut input = EngineInput::new(url.clone(), fetched.body);
input.content_type = fetched.content_type;
let child = match render(&input) {
Ok(child) => child,
Err(error) => {
outcome.failed.push((url, error));
keep(block, &mut blocks, &mut outcome);
continue;
}
};
let source = BlockProvenance::from_document(child.provenance.clone());
for (offset, child_block) in child.blocks.into_iter().enumerate() {
outcome
.provenance
.insert(new_index + offset, source.clone());
blocks.push(child_block);
}
outcome.resolved += 1;
any_resolved = true;
}
document.blocks = blocks;
if !any_resolved {
break;
}
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{DocumentProvenance, DocumentTrustState, InlineSpan};
fn doc_with(blocks: Vec<Block>) -> EngineDocument {
EngineDocument {
address: "test.knot".into(),
title: None,
content_type: "text/x-knot".into(),
lang: None,
provenance: DocumentProvenance::default(),
trust: DocumentTrustState::Unknown,
diagnostics: Vec::new(),
blocks,
}
}
fn include_fence(url: &str, fallback: &str) -> Block {
Block::CodeBlock {
language: Some(format!("include {url}")),
text: fallback.to_string(),
}
}
fn stub_render(input: &EngineInput) -> Result<EngineDocument, String> {
let mut child = doc_with(vec![Block::Paragraph {
spans: vec![InlineSpan::Text(input.body.clone())],
}]);
child.provenance.canonical_uri = Some(input.address.clone());
Ok(child)
}
fn policy(schemes: &[&str], depth: u8) -> TransclusionPolicy {
TransclusionPolicy::for_own_notes(schemes.iter().map(|s| s.to_string()).collect(), depth)
}
#[test]
fn an_allowed_include_splices_with_provenance() {
let mut document = doc_with(vec![
Block::Paragraph {
spans: vec![InlineSpan::Text("before".into())],
},
include_fence("gemini://x.test/page.gmi", "fallback"),
]);
let mut fetch = |url: &str| {
assert_eq!(url, "gemini://x.test/page.gmi");
Ok(Fetched {
content_type: Some("text/gemini".into()),
body: "from the capsule".into(),
})
};
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut stub_render,
&policy(&["gemini"], 1),
);
assert_eq!(outcome.resolved, 1);
assert!(outcome.denied.is_empty() && outcome.failed.is_empty());
assert_eq!(document.blocks.len(), 2);
assert!(matches!(
&document.blocks[1],
Block::Paragraph { spans } if spans == &vec![InlineSpan::Text("from the capsule".into())]
));
let provenance = outcome
.provenance
.get(1)
.expect("spliced block has provenance");
assert_eq!(
provenance.provenance.canonical_uri.as_deref(),
Some("gemini://x.test/page.gmi")
);
}
#[test]
fn policy_denies_keep_the_fallback_visible() {
let mut document = doc_with(vec![include_fence("https://x.test/", "fallback")]);
let mut fetch = |_: &str| -> Result<Fetched, String> {
panic!("fetch must not run for a denied fence")
};
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut stub_render,
&TransclusionPolicy::deny_all(),
);
assert_eq!(outcome.resolved, 0);
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut stub_render,
&policy(&["gemini"], 1),
);
assert_eq!(outcome.denied.len(), 1);
assert!(outcome.denied[0].1.contains("allowlist"));
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
}
#[test]
fn depth_and_cycles_are_capped() {
let mut document = doc_with(vec![include_fence("gemini://x.test/a", "")]);
let mut fetch = |_: &str| {
Ok(Fetched {
content_type: None,
body: "irrelevant".into(),
})
};
let mut render = |input: &EngineInput| -> Result<EngineDocument, String> {
Ok(doc_with(vec![include_fence("gemini://x.test/a", "")])).map(|mut d| {
d.provenance.canonical_uri = Some(input.address.clone());
d
})
};
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut render,
&policy(&["gemini"], 3),
);
assert_eq!(outcome.resolved, 1, "the cycle resolves once");
assert!(
outcome.denied.iter().any(|(_, r)| r.contains("cycle")),
"the second visit is refused: {:?}",
outcome.denied
);
let mut document = doc_with(vec![include_fence("gemini://x.test/b", "")]);
let mut render_chain = |input: &EngineInput| -> Result<EngineDocument, String> {
let mut d = doc_with(vec![include_fence("gemini://x.test/c", "")]);
d.provenance.canonical_uri = Some(input.address.clone());
Ok(d)
};
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut render_chain,
&policy(&["gemini"], 1),
);
assert_eq!(outcome.resolved, 1);
assert!(
matches!(&document.blocks[0], Block::CodeBlock { .. }),
"the nested fence stays inert at depth 1"
);
}
#[test]
fn fetch_failures_keep_the_fallback_and_report() {
let mut document = doc_with(vec![include_fence("gemini://down.test/", "fallback")]);
let mut fetch = |_: &str| -> Result<Fetched, String> { Err("connection refused".into()) };
let outcome = resolve_transclusions(
&mut document,
&mut fetch,
&mut stub_render,
&policy(&["gemini"], 2),
);
assert_eq!(outcome.failed.len(), 1);
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
}
#[test]
fn parse_include_is_strict() {
assert_eq!(
parse_include("include gemini://x.test/"),
Some("gemini://x.test/")
);
assert_eq!(parse_include("include"), None);
assert_eq!(parse_include("rust"), None);
assert_eq!(parse_include("included gemini://x.test/"), None);
}
}