use std::sync::{Arc, Mutex};
use super::effects::{self, BodyEffects, CLASSES, StepClass};
use crate::graph::{AccessRequest, AccessRequestBuf, Action, Authorizer, Denied, Resource};
use crate::script::{ScriptLimits, engine, eval_value_with_limits, registered_step_names};
#[derive(Default)]
struct Recorder(Mutex<Vec<AccessRequestBuf>>);
impl Authorizer for Recorder {
fn authorize(&self, request: &AccessRequest<'_>) -> Result<(), Denied> {
if matches!(
request.action,
Action::Create | Action::Update | Action::Delete
) && let Ok(mut seen) = self.0.lock()
{
let request = request.to_owned_request();
if !seen.contains(&request) {
seen.push(request);
}
}
Ok(())
}
}
fn recorded(script: &str) -> (Vec<AccessRequestBuf>, Result<(), String>) {
let recorder = Arc::new(Recorder::default());
let outcome = eval_value_with_limits(
Arc::new(crate::GraphSource::tinkerpop_modern()),
script,
Default::default(),
&ScriptLimits::default(),
recorder.clone(),
)
.map(|_| ())
.map_err(|err| err.to_string());
let writes = recorder
.0
.lock()
.map(|seen| seen.clone())
.unwrap_or_default();
(writes, outcome)
}
fn inferred(script: &str) -> Result<BodyEffects, String> {
let ast = engine().compile(script).map_err(|err| err.to_string())?;
effects::analyze(&ast)
}
fn texts(requests: &[AccessRequestBuf]) -> Vec<String> {
let mut texts: Vec<String> = requests.iter().map(ToString::to_string).collect();
texts.sort();
texts
}
#[test]
fn every_classified_name_is_registered() {
let registered = registered_step_names();
let mut unknown = Vec::new();
for (names, class) in CLASSES {
for name in *names {
if *class == StepClass::Dynamic {
continue;
}
if !registered.iter().any(|known| known == name) {
unknown.push(*name);
}
}
}
assert!(unknown.is_empty(), "not registered: {unknown:?}");
}
#[test]
fn chains_infer_exactly_the_requests_of_the_authorization_check() {
let cases: &[(&str, &[&str])] = &[
(
r#"g.add_v("person").property("name", "x").iterate()"#,
&[
r#"Create Data(vertex "person")"#,
r#"Update Data(vertex "person")"#,
],
),
(
r#"g.add_v(["a", "b"]).iterate()"#,
&[r#"Create Data(vertex "a")"#, r#"Create Data(vertex "b")"#],
),
(r#"g.add_v().iterate()"#, &["Create Data(vertex)"]),
(
r#"g.e().has_label("knows").drop().iterate()"#,
&[r#"Delete Data(edge "knows")"#],
),
(
r#"g.e().has_label("knows", "created").limit(1).property("w", 1).iterate()"#,
&[
r#"Update Data(edge "created")"#,
r#"Update Data(edge "knows")"#,
],
),
(
r#"g.v().has_label("person").property("x", 1).iterate()"#,
&["Update Data(vertex)"],
),
(
r#"g.v().out_e("knows").order().property("w", 1).iterate()"#,
&[r#"Update Data(edge "knows")"#],
),
(
r#"g.v().limit(2).drop().iterate()"#,
&["Delete Data(vertex)"],
),
(
r#"g.v().values("name").limit(1).drop().iterate()"#,
&["Delete Data"],
),
(
r#"g.v().side_effect(__.property("x", 1)).iterate()"#,
&["Update Data"],
),
(
r#"g.add_e("knows").from(__.v("1")).to(__.v("2")).property("w", 1).iterate()"#,
&[
r#"Create Data(edge "knows")"#,
r#"Update Data(edge "knows")"#,
],
),
(
r#"g.v().add_label("x").property("a", 1).iterate()"#,
&["Update Data(vertex)"],
),
(
r#"g.e().has_label("knows").set_label("x").property("a", 1).iterate()"#,
&[r#"Update Data(edge "knows")"#, "Update Data(edge)"],
),
(
r#"g.merge_v(#{name: "x"}).iterate()"#,
&["Create Data(vertex)", "Update Data(vertex)"],
),
(
r#"let t = g.e().has_label("knows"); t.drop().iterate()"#,
&["Delete Data"],
),
];
for (script, expected) in cases {
let (writes, outcome) = recorded(script);
assert!(outcome.is_ok(), "{script}: {outcome:?}");
let analysis = inferred(script).unwrap();
let mut expected: Vec<String> = expected.iter().map(ToString::to_string).collect();
expected.sort();
assert_eq!(texts(&analysis.writes), expected, "inferred for {script}");
if !script.starts_with("let ") {
assert_eq!(texts(&writes), expected, "asked for {script}");
}
for write in &writes {
assert!(
analysis
.writes
.iter()
.any(|effect| effects::covers(effect, &write.as_request())),
"{script}: {write} not covered"
);
}
}
}
#[test]
fn the_inference_covers_every_write_of_every_documented_example() {
let mut problems = Vec::new();
let mut writing = 0;
for (receiver, name, example, result) in crate::script::doc_examples() {
if result.is_none() {
continue;
}
let (writes, _) = recorded(example);
if writes.is_empty() {
continue;
}
writing += 1;
match inferred(example) {
Err(_) => {}
Ok(analysis) => {
for write in &writes {
let request = write.as_request();
let covered = match request.resource {
Resource::Data { .. } => {
analysis.dynamic.is_some()
|| !analysis.calls.is_empty()
|| analysis
.writes
.iter()
.any(|effect| effects::covers(effect, &request))
}
_ => false,
};
if !covered {
problems.push(format!(
"{receiver}.{name}: `{example}` asks {write}, inferred {:?}",
texts(&analysis.writes)
));
}
}
}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
assert!(writing > 30, "only {writing} documented examples write");
}
#[test]
fn constructs_that_hide_calls_make_a_body_dynamic() {
for (script, construct) in [
(r#"fn q() { let f = Fn("drop"); 1 }"#, r#"Fn("drop")"#),
(r#"fn q(f) { f.call(1) }"#, "call"),
(r#"fn q(f) { call(f, 1) }"#, "call"),
(r#"fn q(f) { f.curry(1) }"#, "curry"),
(r#"fn q() { eval("1") }"#, "eval"),
(
r#"fn q(n) { g.query(n) }"#,
"g.query(name) with a computed name",
),
(r#"fn q() { import "m" as m; 1 }"#, "import"),
] {
let analysis = inferred(script).unwrap();
assert_eq!(analysis.dynamic.as_deref(), Some(construct), "{script}");
}
let plain = inferred(r#"fn q() { g.query("other", #{a: 1}).count() }"#).unwrap();
assert_eq!(plain.dynamic, None);
assert_eq!(plain.calls, ["other"]);
}
#[test]
fn writes_of_the_schema_the_catalog_and_files_are_refused() {
for (script, part) in [
(
r#"fn q() { g.set_schema(#{}) }"#,
"set_schema(), and it changes the schema",
),
(
r#"fn q() { g.drop_query("x") }"#,
"drop_query(), and it changes the catalog",
),
(
r#"fn q() { g.export_graphml("x.xml") }"#,
"export_graphml(), and it reads or writes a file",
),
(r#"fn q() { g.mark("m") }"#, "mark()"),
] {
let reason = inferred(script).unwrap_err();
assert!(reason.contains(part), "{script}: {reason}");
}
}
#[test]
fn soundness_cases_over_approximate() {
let analysis = inferred(r#"fn q(l) { g.add_v(l).iterate() }"#).unwrap();
assert_eq!(texts(&analysis.writes), ["Create Data(vertex)"]);
let analysis = inferred(r#"fn q() { drop(g.e().has_label("knows")).iterate() }"#).unwrap();
assert_eq!(texts(&analysis.writes), ["Delete Data"]);
let analysis = inferred(r#"fn q() { let m = #{}; m.drop() }"#).unwrap();
assert_eq!(texts(&analysis.writes), ["Delete Data"]);
let analysis =
inferred(r#"fn q(t) { g.with_side_effect("sg", t).e().subgraph("sg").iterate() }"#)
.unwrap();
assert_eq!(texts(&analysis.writes), ["Create Data"]);
let analysis = inferred(r#"fn q() { g.with_side_effect("x", [1]).v().iterate() }"#).unwrap();
assert!(analysis.writes.is_empty());
let analysis = inferred(r#"fn q() { g.v().has_label("person").values("name") }"#).unwrap();
assert_eq!(analysis, BodyEffects::default());
}
#[test]
fn transitive_effects_follow_calls_that_count_once() {
let node = |writes: &[&str], calls: &[&str], counts: bool| effects::Node {
effects: BodyEffects {
writes: writes
.iter()
.map(|label| {
AccessRequest::new(Action::Create, Resource::data(None, Some(label)))
.to_owned_request()
})
.collect(),
calls: calls.iter().map(ToString::to_string).collect(),
dynamic: None,
},
counts,
};
let mut lookup = |name: &str| match name {
"a" => Some(node(&["a"], &["b", "c", "a"], true)),
"b" => Some(node(&["b"], &["a"], true)),
"c" => Some(node(&["c"], &[], false)),
_ => None,
};
let (writes, dynamic) = effects::transitive("a", &mut lookup);
assert_eq!(
texts(&writes),
[r#"Create Data("a")"#, r#"Create Data("b")"#]
);
assert_eq!(dynamic, None);
}