use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};
use oxrdf::{Term, Triple};
use serde::Deserialize;
use crate::name::{is_layer_name, is_short_name, Participant};
use crate::rdf::{self, object_iri, subject_iri};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfigError(pub String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "layer host configuration: {}", self.0)
}
}
impl std::error::Error for ConfigError {}
fn err<T>(message: impl Into<String>) -> Result<T, ConfigError> {
Err(ConfigError(message.into()))
}
pub const DEFAULT_ENTRY_MAX: usize = 16 * 1024;
pub const MAX_ENTRY_MAX: usize = 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Model {
Channel,
Tuplespace,
}
impl Model {
pub fn as_str(self) -> &'static str {
match self {
Model::Channel => "channel",
Model::Tuplespace => "tuplespace",
}
}
pub fn verbs(self) -> &'static [&'static str] {
match self {
Model::Channel => &["read", "feed", "say"],
Model::Tuplespace => &["read", "feed", "out", "take"],
}
}
fn parse(word: &str) -> Result<Model, String> {
match word {
"channel" => Ok(Model::Channel),
"tuplespace" => Ok(Model::Tuplespace),
"graph" => {
Err("model `graph` is not built in this version (ledger #1170, arc 14)".into())
}
other => Err(format!(
"unknown model `{other}` (this version builds: channel, tuplespace)"
)),
}
}
}
#[derive(Clone, Debug)]
pub struct LayerDef {
pub name: String,
pub model: Model,
pub label: Option<String>,
pub shapes: String,
pub target_classes: BTreeSet<String>,
pub context: String,
pub entry_max: usize,
pub about: String,
pub addressed: bool,
pub shared: Option<BTreeSet<String>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LayerToml {
model: String,
shapes: String,
context: String,
label: Option<String>,
entry_max: Option<toml::Value>,
about: Option<String>,
addressed: Option<bool>,
shared: Option<Vec<String>>,
}
pub fn load_layers(dir: &Path) -> Result<BTreeMap<String, LayerDef>, ConfigError> {
let layers_dir = dir.join("layers");
let mut layers = BTreeMap::new();
for path in toml_files(&layers_dir)? {
let def = load_layer(&layers_dir, &path)?;
layers.insert(def.name.clone(), def);
}
Ok(layers)
}
fn toml_files(dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return err(format!("read {}: {e}", dir.display())),
};
let mut files = Vec::new();
for entry in entries {
let path = entry
.map_err(|e| ConfigError(format!("read {}: {e}", dir.display())))?
.path();
if path.extension().is_some_and(|x| x == "toml") {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn read(path: &Path) -> Result<String, ConfigError> {
std::fs::read_to_string(path).map_err(|e| ConfigError(format!("read {}: {e}", path.display())))
}
fn stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn load_layer(layers_dir: &Path, path: &Path) -> Result<LayerDef, ConfigError> {
let name = stem(path);
let at = path.display();
if !is_layer_name(&name) {
return err(format!(
"{at}: `{name}` is not a layer name (lowercase [a-z0-9-], at most {} characters, \
so its longest MCP tool name fits in 64)",
crate::name::MAX_LAYER_NAME
));
}
let parsed: LayerToml =
toml::from_str(&read(path)?).map_err(|e| ConfigError(format!("{at}: {e}")))?;
let model = Model::parse(&parsed.model).map_err(|e| ConfigError(format!("{at}: {e}")))?;
match model {
Model::Channel if parsed.addressed.is_some() => {
return err(format!(
"{at}: `addressed` is a tuplespace option; a channel has no take"
))
}
Model::Tuplespace if parsed.about.is_some() => {
return err(format!(
"{at}: `about` is a channel option (its `latest about=` read); a tuplespace \
has no latest"
))
}
_ => {}
}
let shapes = read(&beside(layers_dir, &parsed.shapes, path)?)?;
let target_classes = check_shapes(&shapes)
.map_err(|e| ConfigError(format!("{at}: shapes `{}`: {e}", parsed.shapes)))?;
let shapes = skolemize(
&shapes,
&format!("{}:shapes:", crate::name::layer_iri(&name)),
)
.map_err(|e| ConfigError(format!("{at}: shapes `{}`: {e}", parsed.shapes)))?;
let context = read(&beside(layers_dir, &parsed.context, path)?)?;
check_context(&context)
.map_err(|e| ConfigError(format!("{at}: context `{}`: {e}", parsed.context)))?;
let entry_max = match &parsed.entry_max {
None => DEFAULT_ENTRY_MAX,
Some(value) => {
parse_size(value).map_err(|e| ConfigError(format!("{at}: entry_max {e}")))?
}
};
let about = match parsed.about {
None => rdf::DCT_SUBJECT.to_string(),
Some(iri) => {
oxrdf::NamedNode::new(&iri)
.map_err(|e| ConfigError(format!("{at}: about `{iri}` is not an IRI: {e}")))?;
iri
}
};
let shared = match parsed.shared {
None => None,
Some(names) => {
if let Some(bad) = names.iter().find(|n| !is_short_name(n)) {
return err(format!(
"{at}: shared names `{bad}`, which is no collaboration name"
));
}
Some(names.into_iter().collect())
}
};
Ok(LayerDef {
name,
model,
label: parsed.label,
shapes,
target_classes,
context,
entry_max,
about,
addressed: parsed.addressed.unwrap_or(false),
shared,
})
}
fn beside(layers_dir: &Path, file: &str, def: &Path) -> Result<PathBuf, ConfigError> {
let plain =
!file.is_empty() && !file.starts_with('.') && !file.contains('/') && !file.contains('\\');
if !plain {
return err(format!(
"{}: `{file}` must be a file name in layers/, not a path",
def.display()
));
}
Ok(layers_dir.join(file))
}
fn parse_size(value: &toml::Value) -> Result<usize, String> {
let bytes = match value {
toml::Value::Integer(n) if *n > 0 => *n as u64,
toml::Value::String(s) => {
let s = s.trim();
let (digits, unit) = s
.find(|c: char| !c.is_ascii_digit())
.map(|i| s.split_at(i))
.unwrap_or((s, ""));
let n: u64 = digits
.parse()
.map_err(|_| format!("`{s}` is not a size (e.g. 16KiB)"))?;
let factor = match unit.trim() {
"" | "B" => 1,
"KiB" => 1024,
"MiB" => 1024 * 1024,
other => return Err(format!("unit `{other}` (use B, KiB or MiB)")),
};
n.saturating_mul(factor)
}
_ => return Err("must be a positive integer or a size string".into()),
};
if bytes == 0 || bytes > MAX_ENTRY_MAX as u64 {
return Err(format!("{bytes} is outside 1..={MAX_ENTRY_MAX} bytes"));
}
Ok(bytes as usize)
}
pub fn check_shapes(turtle: &str) -> Result<BTreeSet<String>, String> {
let triples = rdf::parse_turtle(turtle)?;
let mut node_shapes = BTreeSet::new();
let mut closed = BTreeSet::new();
let mut targets = BTreeSet::new();
for t in &triples {
let s = t.subject.to_string();
match t.predicate.as_str() {
rdf::RDF_TYPE if object_iri(&t.object) == Some(rdf::SH_NODE_SHAPE) => {
node_shapes.insert(s);
}
rdf::SH_TARGET_CLASS => {
node_shapes.insert(s.clone());
match object_iri(&t.object) {
Some(class) => {
targets.insert(class.to_string());
}
None => return Err(format!("{s}: sh:targetClass must be an IRI")),
}
}
rdf::SH_CLOSED => {
if let Term::Literal(l) = &t.object {
if l.value() == "true" {
closed.insert(s);
}
}
}
_ => {}
}
}
if let Some(open) = node_shapes.iter().find(|s| !closed.contains(*s)) {
return Err(format!(
"node shape {open} is not closed (`sh:closed true`): an open shape admits every \
predicate it does not mention"
));
}
if targets.is_empty() {
return Err(
"no node shape targets a class (`sh:targetClass`), so nothing could be \
admitted"
.into(),
);
}
Ok(targets)
}
pub fn skolemize(turtle: &str, prefix: &str) -> Result<String, String> {
use oxrdf::{NamedNode, NamedOrBlankNode};
let mut names: BTreeMap<String, NamedNode> = BTreeMap::new();
let mut name = |b: &oxrdf::BlankNode| -> NamedNode {
let next = names.len();
names
.entry(b.as_str().to_string())
.or_insert_with(|| rdf::node(&format!("{prefix}n{next}")))
.clone()
};
let triples: Vec<Triple> = rdf::parse_turtle(turtle)?
.into_iter()
.map(|t| {
let subject = match t.subject {
NamedOrBlankNode::BlankNode(b) => NamedOrBlankNode::NamedNode(name(&b)),
other => other,
};
let object = match t.object {
Term::BlankNode(b) => Term::NamedNode(name(&b)),
other => other,
};
Triple::new(subject, t.predicate, object)
})
.collect();
Ok(rdf::to_turtle_with(
&triples,
&[("sh", "http://www.w3.org/ns/shacl#")],
))
}
pub fn check_context(json: &str) -> Result<(), String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("not JSON: {e}"))?;
let context = value.get("@context").ok_or("no `@context` member")?;
check_context_value(context)
}
fn check_context_value(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(map) => {
for (key, inner) in map {
if matches!(key.as_str(), "@vocab" | "@base" | "@import") {
return Err(format!(
"`{key}` is refused: a layer context is an allowlist, and `{key}` would \
admit terms it does not define"
));
}
if key == "@context" {
check_context_value(inner)?;
} else if let serde_json::Value::Object(term) = inner {
if let Some(scoped) = term.get("@context") {
check_context_value(scoped)?;
}
}
}
Ok(())
}
serde_json::Value::Array(items) => items.iter().try_for_each(check_context_value),
serde_json::Value::String(remote) => Err(format!(
"remote context `{remote}` is refused: a layer context is inline, so what crosses \
is what this file says"
)),
serde_json::Value::Null => Ok(()),
_ => Err("a context is an object, an array of objects, or null".into()),
}
}
#[derive(Clone, Debug)]
pub struct Member {
pub participant: Participant,
pub types: BTreeSet<String>,
pub label: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct Participants {
pub triples: Vec<Triple>,
pub members: BTreeMap<String, Member>,
}
impl Participants {
pub fn names(&self, iri: &str) -> bool {
self.members.contains_key(iri)
}
}
pub fn load_participants(dir: &Path) -> Result<Participants, ConfigError> {
let path = dir.join("participants.ttl");
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Participants::default()),
Err(e) => return err(format!("read {}: {e}", path.display())),
};
let at = path.display();
let triples = rdf::parse_turtle(&text).map_err(|e| ConfigError(format!("{at}: {e}")))?;
let mut members: BTreeMap<String, Member> = BTreeMap::new();
for t in &triples {
if rdf::has_blank_node(t) {
return err(format!("{at}: a blank node; name every node by an IRI"));
}
let Some(subject) = subject_iri(&t.subject) else {
continue;
};
if !members.contains_key(subject) {
let participant =
Participant::parse(subject).map_err(|e| ConfigError(format!("{at}: {e}")))?;
members.insert(
subject.to_string(),
Member {
participant,
types: BTreeSet::new(),
label: None,
},
);
}
let member = members.get_mut(subject).expect("inserted above");
match t.predicate.as_str() {
rdf::RDF_TYPE => {
if let Some(class) = object_iri(&t.object) {
member.types.insert(class.to_string());
}
}
rdf::RDFS_LABEL => {
if let Term::Literal(l) = &t.object {
member.label = Some(l.value().to_string());
}
}
_ => {}
}
}
if let Some((iri, _)) = members.iter().find(|(_, m)| m.types.is_empty()) {
return err(format!("{at}: participant {iri} has no rdf:type"));
}
Ok(Participants { triples, members })
}
#[derive(Clone, Debug)]
pub struct Mount {
pub layer: String,
pub grants: BTreeMap<Participant, BTreeSet<String>>,
}
#[derive(Clone, Debug)]
pub struct Manifest {
pub name: String,
pub label: Option<String>,
pub mounts: Vec<Mount>,
}
pub fn load_collabs(
dir: &Path,
layers: &BTreeMap<String, LayerDef>,
participants: &Participants,
) -> Result<BTreeMap<String, Manifest>, ConfigError> {
let collabs_dir = dir.join("collabs");
let mut manifests = BTreeMap::new();
for path in toml_files(&collabs_dir)? {
let manifest = load_manifest(&path, layers, participants)?;
manifests.insert(manifest.name.clone(), manifest);
}
check_isolation(&manifests, layers)?;
Ok(manifests)
}
fn load_manifest(
path: &Path,
layers: &BTreeMap<String, LayerDef>,
participants: &Participants,
) -> Result<Manifest, ConfigError> {
let name = stem(path);
let at = path.display();
if !is_short_name(&name) {
return err(format!(
"{at}: `{name}` is not a collaboration name (lowercase [a-z0-9-])"
));
}
let table: toml::Table =
toml::from_str(&read(path)?).map_err(|e| ConfigError(format!("{at}: {e}")))?;
let mut label = None;
let mut mounts: Vec<Mount> = Vec::new();
for (key, value) in &table {
match (key.as_str(), value) {
("label", toml::Value::String(s)) => label = Some(s.clone()),
("mount", toml::Value::Array(items)) => {
for item in items {
let toml::Value::Table(mount) = item else {
return err(format!("{at}: each [[mount]] is a table"));
};
let mount = load_mount(&at.to_string(), mount, layers, participants)?;
if mounts.iter().any(|m| m.layer == mount.layer) {
return err(format!("{at}: layer `{}` is mounted twice", mount.layer));
}
mounts.push(mount);
}
}
(other, _) => {
return err(format!(
"{at}: unknown key `{other}` (a manifest has `label` and [[mount]] tables)"
))
}
}
}
Ok(Manifest {
name,
label,
mounts,
})
}
fn load_mount(
at: &str,
mount: &toml::Table,
layers: &BTreeMap<String, LayerDef>,
participants: &Participants,
) -> Result<Mount, ConfigError> {
let layer = match mount.get("layer") {
Some(toml::Value::String(layer)) => layer.clone(),
_ => return err(format!("{at}: a [[mount]] names its `layer`")),
};
let Some(def) = layers.get(&layer) else {
return err(format!(
"{at}: mounts layer `{layer}`, which no definition defines"
));
};
let mut grants = BTreeMap::new();
for (key, value) in mount.iter().filter(|(k, _)| *k != "layer") {
let participant =
Participant::from_manifest_key(key).map_err(|e| ConfigError(format!("{at}: {e}")))?;
if !participants.names(participant.iri()) {
return err(format!(
"{at}: layer `{layer}` grants {}, whom participants.ttl does not name",
participant.iri()
));
}
let toml::Value::Array(items) = value else {
return err(format!("{at}: `{key}` is a list of verbs"));
};
let mut verbs = BTreeSet::new();
for item in items {
let Some(verb) = item.as_str() else {
return err(format!("{at}: `{key}` lists a verb that is not a string"));
};
if !def.model.verbs().contains(&verb) {
return err(format!(
"{at}: `{key}` holds `{verb}` on `{layer}`, a {} layer, whose verbs are {}",
def.model.as_str(),
def.model.verbs().join(", ")
));
}
verbs.insert(verb.to_string());
}
if grants.insert(participant.clone(), verbs).is_some() {
return err(format!(
"{at}: {} is named twice on `{layer}`",
participant.iri()
));
}
}
Ok(Mount { layer, grants })
}
fn check_isolation(
manifests: &BTreeMap<String, Manifest>,
layers: &BTreeMap<String, LayerDef>,
) -> Result<(), ConfigError> {
let mut mounted_by: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for manifest in manifests.values() {
for mount in &manifest.mounts {
mounted_by
.entry(mount.layer.as_str())
.or_default()
.insert(manifest.name.as_str());
}
}
for (layer, by) in mounted_by {
let def = &layers[layer];
match &def.shared {
None if by.len() > 1 => {
return err(format!(
"layer `{layer}` is mounted by {} but declares no `shared` list: a layer \
belongs to one collaboration unless its definition says \
`shared = [..]` naming every collaboration that mounts it",
list(&by)
))
}
Some(shared) => {
let outside: BTreeSet<&str> = by
.iter()
.copied()
.filter(|m| !shared.contains(*m))
.collect();
if !outside.is_empty() {
return err(format!(
"layer `{layer}` is shared with {} only, and is mounted by {} too",
list(&shared.iter().map(String::as_str).collect()),
list(&outside)
));
}
}
None => {}
}
}
Ok(())
}
fn list(names: &BTreeSet<&str>) -> String {
names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Clone, Debug)]
pub struct Config {
pub dir: PathBuf,
pub layers: BTreeMap<String, LayerDef>,
pub participants: Participants,
pub collabs: BTreeMap<String, Manifest>,
}
impl Config {
pub fn load(dir: impl AsRef<Path>) -> Result<Config, ConfigError> {
let dir = dir.as_ref().to_path_buf();
let layers = load_layers(&dir)?;
let participants = load_participants(&dir)?;
let collabs = load_collabs(&dir, &layers, &participants)?;
Ok(Config {
dir,
layers,
participants,
collabs,
})
}
}