1use std::collections::{BTreeMap, BTreeSet};
62
63use ikigai_core::{
64 is_deny_scope, ArgRef, Capability, Error, Invocation, Iri, Request, Result, Verb,
65};
66use oxrdf::{NamedOrBlankNode, Term, Triple};
67use oxttl::TurtleParser;
68use serde::{Deserialize, Serialize};
69
70use crate::authority::Ceiling;
71
72pub const EVAL: &str = "urn:plan:eval";
75pub const VALIDATE: &str = "urn:plan:validate";
77pub const REQUIRES: &str = "urn:plan:requires";
79
80pub const MAX_PLAN_BYTES: usize = 1 << 20;
84
85pub const RESERVED_PARAMETERS: [&str; 8] = [
90 "in",
91 "as",
92 "name",
93 "content",
94 "received",
95 "client",
96 "principal",
97 "content-type",
98];
99
100const IK: &str = "https://ikigai-rs.dev/ns#";
101const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
102const RDFS_COMMENT: &str = "http://www.w3.org/2000/01/rdf-schema#comment";
103const SH: &str = "http://www.w3.org/ns/shacl#";
104const TURTLE: &str = "text/turtle";
105const OUTCOME: &str = "urn:ikigai:plan:requires:outcome:";
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Step {
111 pub iri: String,
113 pub verb: String,
115 pub resolves: String,
117}
118
119impl Step {
120 pub fn mutates(&self) -> bool {
122 matches!(self.verb.as_str(), "Sink" | "Delete")
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct Parameter {
129 pub name: String,
131 pub required: bool,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub default: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub class: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub summary: Option<String>,
142}
143
144impl Parameter {
145 pub fn must_be_given(&self) -> bool {
147 self.required && self.default.is_none()
148 }
149}
150
151#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Analysis {
154 pub process: String,
156 pub steps: Vec<Step>,
158 pub parameters: Vec<Parameter>,
160}
161
162impl Analysis {
163 pub fn mutates(&self) -> bool {
168 self.steps.iter().any(Step::mutates)
169 }
170}
171
172struct Graph {
178 triples: Vec<Triple>,
179}
180
181fn key(node: &NamedOrBlankNode) -> String {
184 match node {
185 NamedOrBlankNode::NamedNode(n) => n.as_str().to_string(),
186 NamedOrBlankNode::BlankNode(b) => format!("_:{}", b.as_str()),
187 }
188}
189
190fn object_key(term: &Term) -> Option<String> {
191 if let Term::NamedNode(n) = term {
192 return Some(n.as_str().to_string());
193 }
194 if let Term::BlankNode(b) = term {
195 return Some(format!("_:{}", b.as_str()));
196 }
197 None
198}
199
200fn object_literal(term: &Term) -> Option<&str> {
201 if let Term::Literal(l) = term {
202 return Some(l.value());
203 }
204 None
205}
206
207impl Graph {
208 fn parse(text: &str, arg: &str, what: &str) -> Result<Graph> {
209 let triples = TurtleParser::new()
210 .for_slice(text.as_bytes())
211 .collect::<std::result::Result<Vec<_>, _>>()
212 .map_err(|e| Error::InvalidArgument {
213 name: arg.to_string(),
214 detail: format!("{what} is not valid Turtle: {e}"),
215 })?;
216 Ok(Graph { triples })
217 }
218
219 fn objects<'a>(
220 &'a self,
221 subject: &'a str,
222 predicate: &'a str,
223 ) -> impl Iterator<Item = &'a Term> {
224 self.triples
225 .iter()
226 .filter(move |t| key(&t.subject) == subject && t.predicate.as_str() == predicate)
227 .map(|t| &t.object)
228 }
229
230 fn of_class(&self, class: &str) -> BTreeSet<String> {
231 self.triples
232 .iter()
233 .filter(|t| {
234 t.predicate.as_str() == RDF_TYPE && object_key(&t.object).as_deref() == Some(class)
235 })
236 .map(|t| key(&t.subject))
237 .collect()
238 }
239
240 fn with_predicate(&self, predicate: &str) -> BTreeSet<String> {
241 self.triples
242 .iter()
243 .filter(|t| t.predicate.as_str() == predicate)
244 .map(|t| key(&t.subject))
245 .collect()
246 }
247
248 fn literal(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
251 let mut values = self.objects(subject, predicate);
252 let first = values.next();
253 if values.next().is_some() {
254 return Err(Error::InvalidArgument {
255 name: arg.to_string(),
256 detail: format!("<{subject}> has more than one <{predicate}>"),
257 });
258 }
259 match first {
260 None => Ok(None),
261 Some(term) => object_literal(term)
262 .map(|v| Some(v.to_string()))
263 .ok_or_else(|| Error::InvalidArgument {
264 name: arg.to_string(),
265 detail: format!("<{subject}> <{predicate}> is not a literal"),
266 }),
267 }
268 }
269
270 fn iri(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
271 let mut values = self.objects(subject, predicate);
272 let first = values.next();
273 if values.next().is_some() {
274 return Err(Error::InvalidArgument {
275 name: arg.to_string(),
276 detail: format!("<{subject}> has more than one <{predicate}>"),
277 });
278 }
279 match first {
280 None => Ok(None),
281 Some(term) => object_key(term)
282 .map(Some)
283 .ok_or_else(|| Error::InvalidArgument {
284 name: arg.to_string(),
285 detail: format!("<{subject}> <{predicate}> is not a node"),
286 }),
287 }
288 }
289}
290
291pub fn check_bound(source: &str) -> Result<()> {
294 if source.len() > MAX_PLAN_BYTES {
295 return Err(Error::InvalidArgument {
296 name: "content".to_string(),
297 detail: format!(
298 "the plan is {} bytes; a plan is at most {MAX_PLAN_BYTES}",
299 source.len()
300 ),
301 });
302 }
303 Ok(())
304}
305
306fn ik(term: &str) -> String {
307 format!("{IK}{term}")
308}
309
310pub fn analyze(source: &str) -> Result<Analysis> {
316 const ARG: &str = "content";
317 check_bound(source)?;
318 let graph = Graph::parse(source, ARG, "the plan")?;
319 let processes = graph.of_class(&ik("Process"));
320 let process = match processes.len() {
321 1 => processes.into_iter().next().expect("one"),
322 0 => {
323 return Err(Error::InvalidArgument {
324 name: ARG.to_string(),
325 detail: "no ik:Process in this graph: it is not a plan".to_string(),
326 })
327 }
328 n => {
329 return Err(Error::InvalidArgument {
330 name: ARG.to_string(),
331 detail: format!("{n} ik:Process nodes in one graph; a plan holds exactly one"),
332 })
333 }
334 };
335
336 let mut step_iris = graph.of_class(&ik("Step"));
337 step_iris.extend(graph.with_predicate(&ik("verb")));
338 let mut steps = Vec::with_capacity(step_iris.len());
339 for iri in step_iris {
340 let verb =
341 graph
342 .literal(&iri, &ik("verb"), ARG)?
343 .ok_or_else(|| Error::InvalidArgument {
344 name: ARG.to_string(),
345 detail: format!("<{iri}> is a step with no ik:verb"),
346 })?;
347 if !matches!(
348 verb.as_str(),
349 "Source" | "Sink" | "Exists" | "Delete" | "Meta"
350 ) {
351 return Err(Error::InvalidArgument {
352 name: ARG.to_string(),
353 detail: format!(
354 "<{iri}> issues \"{verb}\"; a step issues one of Source, Sink, Exists, \
355 Delete, Meta"
356 ),
357 });
358 }
359 let resolves = graph.iri(&iri, &ik("resolves"), ARG)?.unwrap_or_default();
360 steps.push(Step {
361 iri,
362 verb,
363 resolves,
364 });
365 }
366
367 let mut parameters = Vec::new();
368 for node in graph.objects(&process, &ik("input")) {
369 let Some(node) = object_key(node) else {
370 return Err(Error::InvalidArgument {
371 name: ARG.to_string(),
372 detail: format!("<{process}> ik:input is not a node"),
373 });
374 };
375 let name = graph
376 .literal(&node, &ik("inputName"), ARG)?
377 .ok_or_else(|| Error::InvalidArgument {
378 name: ARG.to_string(),
379 detail: format!("<{node}> is a parameter with no ik:inputName"),
380 })?;
381 if RESERVED_PARAMETERS.contains(&name.as_str()) {
382 return Err(Error::InvalidArgument {
383 name: ARG.to_string(),
384 detail: format!(
385 "the parameter `{name}` would never arrive: {} are taken by the doors \
386 a plan runs through",
387 RESERVED_PARAMETERS.join(", ")
388 ),
389 });
390 }
391 let required = match graph.literal(&node, &ik("required"), ARG)?.as_deref() {
392 None | Some("true") | Some("1") => true,
393 Some("false") | Some("0") => false,
394 Some(other) => {
395 return Err(Error::InvalidArgument {
396 name: ARG.to_string(),
397 detail: format!("<{node}> ik:required \"{other}\" is not a boolean"),
398 })
399 }
400 };
401 parameters.push(Parameter {
402 name,
403 required,
404 default: graph.literal(&node, &ik("default"), ARG)?,
405 class: graph.iri(&node, &ik("class"), ARG)?,
406 summary: graph.literal(&node, &ik("summary"), ARG)?,
407 });
408 }
409 parameters.sort_by(|a, b| a.name.cmp(&b.name));
410 if let Some(pair) = parameters.windows(2).find(|w| w[0].name == w[1].name) {
411 return Err(Error::InvalidArgument {
412 name: ARG.to_string(),
413 detail: format!("the parameter `{}` is declared twice", pair[0].name),
414 });
415 }
416 Ok(Analysis {
417 process,
418 steps,
419 parameters,
420 })
421}
422
423fn door_request(door: &str, plan: &str) -> Request {
428 Request::new(Verb::Source, Iri::parse(door).expect("a constant IRI"))
429 .with_arg("in", ArgRef::Inline(plan.as_bytes().to_vec()))
430 .with_arg("as", ArgRef::Inline(TURTLE.as_bytes().to_vec()))
431}
432
433fn from_door(door: &str, error: Error) -> Error {
436 match error {
437 Error::InvalidArgument { name, detail } if name == "in" => Error::InvalidArgument {
438 name: "content".to_string(),
439 detail: format!("{door}: {detail}"),
440 },
441 Error::Unresolved(_) => Error::InvalidArgument {
442 name: "language".to_string(),
443 detail: format!(
444 "this host takes no plans: {door} does not resolve here (a host offers plans \
445 by binding the plan evaluator's three doors, {EVAL}, {VALIDATE} and \
446 {REQUIRES})"
447 ),
448 },
449 other => other,
450 }
451}
452
453pub async fn validate(inv: &Invocation<'_>, plan: &str) -> Result<()> {
457 let report = inv
458 .issue(door_request(VALIDATE, plan))
459 .await
460 .map_err(|e| from_door(VALIDATE, e))?;
461 let text = String::from_utf8_lossy(&report.bytes);
462 let graph = Graph::parse(&text, "content", &format!("{VALIDATE}'s report"))
463 .map_err(|e| Error::Endpoint(e.to_string()))?;
464 let sh = |term: &str| format!("{SH}{term}");
465 let reports = graph.of_class(&sh("ValidationReport"));
466 let Some(report_node) = reports.iter().next() else {
467 return Err(Error::Endpoint(format!(
468 "{VALIDATE} answered a report with no sh:ValidationReport"
469 )));
470 };
471 let conforms = graph
472 .objects(report_node, &sh("conforms"))
473 .any(|o| object_literal(o) == Some("true"));
474 if conforms {
475 return Ok(());
476 }
477 let mut problems = Vec::new();
478 for result in graph.objects(report_node, &sh("result")) {
479 let Some(node) = object_key(result) else {
480 continue;
481 };
482 let first = |predicate: &str| -> Option<String> {
483 graph.objects(&node, &sh(predicate)).next().map(|o| {
484 object_key(o)
485 .or_else(|| object_literal(o).map(str::to_string))
486 .unwrap_or_default()
487 })
488 };
489 let rule = first("sourceConstraint")
490 .or_else(|| first("sourceShape"))
491 .or_else(|| first("sourceConstraintComponent"))
492 .unwrap_or_else(|| "a SHACL constraint".to_string());
493 let focus = first("focusNode").unwrap_or_default();
494 let message = first("resultMessage").unwrap_or_default();
495 problems.push(format!("{rule} at <{focus}>: {message}"));
496 }
497 problems.sort();
498 Err(Error::InvalidArgument {
499 name: "content".to_string(),
500 detail: format!(
501 "the plan does not validate ({VALIDATE}), so it is not stored: {}",
502 if problems.is_empty() {
503 "the report names no result".to_string()
504 } else {
505 problems.join("; ")
506 }
507 ),
508 })
509}
510
511pub async fn derive(
520 inv: &Invocation<'_>,
521 plan: &str,
522 declared: Option<&str>,
523) -> Result<BTreeSet<String>> {
524 let answer = inv
525 .issue(door_request(REQUIRES, plan))
526 .await
527 .map_err(|e| from_door(REQUIRES, e))?;
528 let text = String::from_utf8_lossy(&answer.bytes);
529 let graph = Graph::parse(&text, "content", &format!("{REQUIRES}'s answer"))
530 .map_err(|e| Error::Endpoint(e.to_string()))?;
531 let processes = graph.of_class(&ik("Process"));
532 let [process] = processes.iter().collect::<Vec<_>>()[..] else {
533 return Err(Error::Endpoint(format!(
534 "{REQUIRES} answered {} ik:Process nodes; it describes exactly one plan",
535 processes.len()
536 )));
537 };
538 let outcome = graph.iri(process, &ik("outcome"), "content")?;
539 if outcome.as_deref() != Some(&format!("{OUTCOME}complete")) {
540 let mut unresolved = Vec::new();
541 for step in graph.of_class(&ik("Step")) {
542 let resolved = graph.iri(&step, &ik("outcome"), "content")?;
543 if resolved.as_deref() == Some(&format!("{OUTCOME}resolved")) {
544 continue;
545 }
546 let reasons: Vec<String> = graph
547 .objects(&step, RDFS_COMMENT)
548 .filter_map(object_literal)
549 .map(str::to_string)
550 .collect();
551 unresolved.push(format!(
552 "<{step}> ({})",
553 if reasons.is_empty() {
554 "no reason given".to_string()
555 } else {
556 reasons.join("; ")
557 }
558 ));
559 }
560 return Err(Error::InvalidArgument {
561 name: "content".to_string(),
562 detail: format!(
563 "the plan's authority cannot be derived, so it is not stored: {REQUIRES} could \
564 not read every step's contract, and a partial set would be a floor, not the \
565 requirement. Unresolved: {}",
566 if unresolved.is_empty() {
567 format!("{REQUIRES} answered {outcome:?} and named no step")
568 } else {
569 unresolved.join(", ")
570 }
571 ),
572 });
573 }
574 let derived: BTreeSet<String> = graph
575 .objects(process, &ik("requires"))
576 .filter_map(object_key)
577 .collect();
578 if let Some(bad) = derived.iter().find(|s| is_deny_scope(s)) {
579 return Err(Error::Endpoint(format!(
580 "{REQUIRES} derived the exclusion `{bad}`, which is not a requirement"
581 )));
582 }
583 if let Some(said) = declared {
584 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
585 if said != derived {
586 let listed = |set: &BTreeSet<String>| {
587 if set.is_empty() {
588 "nothing".to_string()
589 } else {
590 set.iter().cloned().collect::<Vec<_>>().join(" ")
591 }
592 };
593 return Err(Error::InvalidArgument {
594 name: "requires".to_string(),
595 detail: format!(
596 "a plan's authority is derived from its steps' contracts, not declared, \
597 and this one derives {}; the `requires` given says {}. Omit `requires`",
598 listed(&derived),
599 listed(&said)
600 ),
601 });
602 }
603 }
604 Ok(derived)
605}
606
607pub fn expand_families(
653 keep: BTreeSet<String>,
654 runner: &Capability,
655 ceiling: &Ceiling,
656) -> BTreeSet<String> {
657 let mut out = BTreeSet::new();
658 for scope in keep {
659 let Some(prefix) = scope.strip_suffix('*').filter(|_| !is_deny_scope(&scope)) else {
660 out.insert(scope);
661 continue;
662 };
663 match runner.scopes() {
664 Some(held) => out.extend(
669 held.iter()
670 .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
671 .filter(|s| ceiling.allows(s))
672 .cloned(),
673 ),
674 None => {
675 if let Ceiling::Scopes(named) = ceiling {
676 out.extend(
677 named
678 .iter()
679 .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
680 .filter(|s| s.strip_suffix('*') != Some(prefix))
683 .cloned(),
684 );
685 }
686 if ceiling.allows(&scope) {
687 out.insert(scope.clone());
688 }
689 }
690 }
691 }
692 out
693}
694
695pub fn markers(keep: &BTreeSet<String>) -> Vec<&str> {
701 keep.iter()
702 .filter(|s| s.ends_with('*') && !is_deny_scope(s))
703 .map(String::as_str)
704 .collect()
705}
706
707pub fn eval_request(
710 program: &str,
711 values: &BTreeMap<String, String>,
712 face: Option<&str>,
713) -> Request {
714 let mut request = Request::new(Verb::Source, Iri::parse(EVAL).expect("a constant IRI"))
715 .with_arg("in", ArgRef::Inline(program.as_bytes().to_vec()));
716 for (name, value) in values {
717 request = request.with_arg(name.clone(), ArgRef::Inline(value.as_bytes().to_vec()));
718 }
719 if let Some(face) = face {
720 request = request.with_arg("as", ArgRef::Inline(face.as_bytes().to_vec()));
721 }
722 request
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728
729 fn set(xs: &[&str]) -> BTreeSet<String> {
730 xs.iter().map(|s| s.to_string()).collect()
731 }
732
733 const TWO_STEPS: &str = r#"
734@prefix ik: <https://ikigai-rs.dev/ns#> .
735<urn:plan:p> a ik:Process ; ik:step <urn:plan:p:step:1> , <urn:plan:p:step:2> ;
736 ik:result <urn:plan:p:step:2> .
737<urn:plan:p:step:1> a ik:Step ; ik:verb "Source" ; ik:resolves <urn:a> .
738<urn:plan:p:step:2> ik:verb "Sink" ; ik:resolves <urn:b> ; ik:pipeFrom <urn:plan:p:step:1> .
739"#;
740
741 #[test]
742 fn a_sink_anywhere_makes_a_write_even_untyped() {
743 let analysis = analyze(TWO_STEPS).unwrap();
744 assert_eq!(analysis.steps.len(), 2);
745 assert!(analysis.mutates());
746 let read = TWO_STEPS.replace("\"Sink\"", "\"Exists\"");
747 assert!(!analyze(&read).unwrap().mutates());
748 }
749
750 #[test]
751 fn the_read_refuses_what_it_cannot_classify() {
752 for (plan, says) in [
753 ("not turtle at all {", "not valid Turtle"),
754 ("<urn:x> <urn:y> <urn:z> .", "no ik:Process"),
755 (
756 &TWO_STEPS.replace("\"Sink\"", "\"Launch\"") as &str,
757 "Launch",
758 ),
759 (
760 &TWO_STEPS.replace("ik:verb \"Sink\"", "ik:verb \"Sink\" , \"Source\""),
761 "more than one",
762 ),
763 ] {
764 match analyze(plan) {
765 Err(Error::InvalidArgument { name, detail }) => {
766 assert_eq!(name, "content");
767 assert!(detail.contains(says), "{says}: {detail}");
768 }
769 other => panic!("{says}: {other:?}"),
770 }
771 }
772 let huge = format!("{TWO_STEPS}#{}", "x".repeat(MAX_PLAN_BYTES));
773 assert!(analyze(&huge).is_err());
774 }
775
776 #[test]
777 fn a_reserved_parameter_is_refused() {
778 let plan = format!(
779 "{TWO_STEPS}<urn:plan:p> ik:input <urn:plan:p:input:in> .\n\
780 <urn:plan:p:input:in> ik:inputName \"in\" .\n"
781 );
782 assert!(matches!(analyze(&plan), Err(Error::InvalidArgument { .. })));
783 }
784
785 #[test]
786 fn a_scoped_runners_own_family_under_the_marker_is_kept_never_wider() {
787 let keep = set(&["urn:cap:script:run:team-*"]);
788 let runner = Capability::scoped([
789 "urn:cap:script:run:outer",
790 "urn:cap:script:run:team-*",
791 "urn:cap:script:run:team-a-*",
792 "urn:cap:script:run:team-b",
793 "urn:cap:script:run:*",
794 "urn:cap:script:run:-team-x",
795 ]);
796 assert_eq!(
800 expand_families(keep.clone(), &runner, &Ceiling::Unbounded),
801 set(&[
802 "urn:cap:script:run:team-*",
803 "urn:cap:script:run:team-a-*",
804 "urn:cap:script:run:team-b"
805 ])
806 );
807 assert_eq!(
809 expand_families(
810 keep.clone(),
811 &runner,
812 &Ceiling::scoped(["urn:cap:script:run:team-a-*", "urn:cap:script:run:team-b"])
813 ),
814 set(&["urn:cap:script:run:team-a-*", "urn:cap:script:run:team-b"])
815 );
816 assert_eq!(
817 expand_families(
818 keep.clone(),
819 &runner,
820 &Ceiling::scoped(["urn:cap:script:run:team-x"])
821 ),
822 set(&[])
823 );
824 let kept = expand_families(keep, &runner, &Ceiling::scoped(["urn:cap:script:run:*"]));
827 assert!(kept.iter().all(|s| runner.allows(s)), "{kept:?}");
828 }
829
830 #[test]
831 fn a_root_publishers_family_becomes_the_runners_members() {
832 let keep = set(&["urn:cap:a", "urn:cap:net:*"]);
833 let runner = Capability::scoped([
834 "urn:cap:a",
835 "urn:cap:net:example.com",
836 "urn:cap:net:other.org",
837 "urn:cap:net:-example.com/admin",
838 ]);
839 let ceiling = Ceiling::scoped(["urn:cap:a", "urn:cap:net:example.com"]);
840 assert_eq!(
841 expand_families(keep.clone(), &runner, &ceiling),
842 set(&["urn:cap:a", "urn:cap:net:example.com"])
843 );
844 assert_eq!(
846 expand_families(keep.clone(), &Capability::root(), &ceiling),
847 set(&["urn:cap:a", "urn:cap:net:example.com"])
848 );
849 assert_eq!(
852 expand_families(keep.clone(), &Capability::root(), &Ceiling::Unbounded),
853 set(&["urn:cap:a", "urn:cap:net:*"])
854 );
855 assert_eq!(
857 expand_families(
858 keep.clone(),
859 &Capability::root(),
860 &Ceiling::scoped(["urn:cap:a"])
861 ),
862 set(&["urn:cap:a"])
863 );
864 let narrower = Ceiling::scoped([
868 "urn:cap:a",
869 "urn:cap:net:example.*",
870 "urn:cap:net:other.org",
871 "urn:cap:net:-example.com/admin",
872 ]);
873 let expanded = expand_families(keep.clone(), &Capability::root(), &narrower);
874 assert_eq!(
875 expanded,
876 set(&[
877 "urn:cap:a",
878 "urn:cap:net:example.*",
879 "urn:cap:net:other.org"
880 ])
881 );
882 assert_eq!(markers(&expanded), ["urn:cap:net:example.*"]);
883 assert_eq!(
884 expand_families(
885 keep,
886 &Capability::root(),
887 &Ceiling::scoped(["urn:cap:a", "urn:cap:*"])
888 ),
889 set(&["urn:cap:a", "urn:cap:net:*"])
890 );
891 }
892}