use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::capability::{
derive_bundle_capabilities, known_node_capabilities, node_kinds_in_pipeline,
};
use super::report::PolicyRiskLevel;
use crate::contracts::kinds::decode_node_bundle;
use crate::pipeline::model::NodeCapability;
use crate::platform::model::{MultiNodePackageDefinition, ResolvedProjectLayout};
const LARGE_FILE_THRESHOLD_BYTES: usize = 5 * 1024 * 1024;
const UNREADABLE_NOT_TEXT: &str = "the bytes are not UTF-8 text";
fn is_package_notice_path(rel_path: &str) -> bool {
matches!(
rel_path.rsplit('/').next(),
Some("LICENSE" | "LICENSE.marked" | "LICENSE.dompurify" | "MODIFICATIONS")
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackagePolicyEntry {
rel_path: String,
kind: String,
size_bytes: usize,
#[serde(default)]
content: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
unreadable: String,
}
impl PackagePolicyEntry {
pub fn from_bytes(
rel_path: impl Into<String>,
kind: impl Into<String>,
size_bytes: usize,
bytes: &[u8],
) -> Self {
let (content, unreadable) = match std::str::from_utf8(bytes) {
Ok(text) => (text.to_string(), String::new()),
Err(_) => (String::new(), UNREADABLE_NOT_TEXT.to_string()),
};
Self {
rel_path: rel_path.into(),
kind: kind.into(),
size_bytes,
content,
unreadable,
}
}
pub fn unresolved(
rel_path: impl Into<String>,
kind: impl Into<String>,
size_bytes: usize,
reason: impl Into<String>,
) -> Self {
Self {
rel_path: rel_path.into(),
kind: kind.into(),
size_bytes,
content: String::new(),
unreadable: reason.into(),
}
}
pub fn text(&self) -> Option<&str> {
if self.content.is_empty() {
None
} else {
Some(&self.content)
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PackageSafetyReview {
pub nodes_used: Vec<String>,
pub credentials_required: Vec<String>,
pub external_urls: Vec<String>,
pub database_effects: Vec<String>,
pub filesystem_effects: Vec<String>,
#[serde(default)]
pub network_effects: Vec<String>,
#[serde(default)]
pub code_execution: Vec<String>,
pub public_endpoints: Vec<String>,
pub schedules: Vec<String>,
pub large_files: Vec<String>,
pub seed_data: Vec<String>,
#[serde(default)]
pub database_initialization: Vec<DatabaseInitializationReport>,
pub warnings: Vec<String>,
#[serde(default)]
pub violations: Vec<String>,
pub risk_level: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DatabaseInitializationReport {
pub engine: String,
pub source: String,
pub store: String,
pub existing_data_at_risk: bool,
pub statements: BTreeMap<String, usize>,
pub tables: Vec<String>,
pub destructive: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub unreadable: String,
}
#[derive(Debug, Clone, Default)]
pub struct PackageReviewOptions {
pub publish_mode: bool,
pub require_title: bool,
pub require_description: bool,
pub require_cover_image: bool,
pub title: String,
pub description: String,
pub has_cover_image: bool,
pub bundle_internal_paths: bool,
}
pub fn review_package_entries(
layout: &ResolvedProjectLayout,
entries: &[PackagePolicyEntry],
mut warnings: Vec<String>,
options: PackageReviewOptions,
) -> PackageSafetyReview {
let mut findings = PipelineFindings::default();
let mut large_files = Vec::new();
let mut seed_data = Vec::new();
let mut database_initialization = Vec::new();
let mut violations = Vec::new();
if options.require_title && options.title.trim().is_empty() {
warnings.push("package title is empty".to_string());
}
if options.require_description && options.description.trim().is_empty() {
warnings.push("package description is empty".to_string());
}
if options.require_cover_image && !options.has_cover_image {
warnings.push("package has no cover image".to_string());
}
let bundles = carried_node_bundles(entries);
let mut capabilities_by_kind = known_node_capabilities().clone();
let mut unresolved_kinds = BTreeSet::new();
for bundle in &bundles {
for node in &bundle.package.nodes {
if capabilities_by_kind.contains_key(&node.kind) {
violations.push(format!(
"{}: declares node kind '{}', which this build already provides",
bundle.manifest_path, node.kind
));
}
}
let derived = derive_bundle_capabilities(
&bundle.package,
|rel_path| bundle.function_graph(entries, rel_path),
&capabilities_by_kind,
);
capabilities_by_kind.extend(derived.nodes);
unresolved_kinds.extend(derived.unresolved);
}
for entry in entries {
if is_package_notice_path(&entry.rel_path) {
if !entry.unreadable.is_empty()
|| entry.content.trim().is_empty()
|| entry
.content
.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
{
violations.push(format!(
"{}: a package notice must contain readable, nonempty UTF-8 text without binary control characters",
entry.rel_path
));
}
} else if !options.bundle_internal_paths
&& let Some(refused) = layout.refused_file_type(&entry.rel_path)
{
violations.push(format!(
"{}: {refused} is not a file type a package may install",
entry.rel_path
));
}
let reviewed_as_pipeline = is_reviewed_pipeline_path(layout, &entry.rel_path)
|| bundles
.iter()
.any(|bundle| bundle.is_function_path(&entry.rel_path));
if reviewed_as_pipeline {
if !entry.unreadable.is_empty() {
violations.push(format!(
"{}: a pipeline the safety review cannot read ({})",
entry.rel_path, entry.unreadable
));
} else if let Some(source) = entry.text() {
findings.read_pipeline(source, &mut warnings);
}
} else if options.publish_mode {
if let Some(source) = entry.text() {
collect_urls_from_text(source, &mut findings.external_urls);
}
}
if entry.size_bytes >= LARGE_FILE_THRESHOLD_BYTES {
large_files.push(format!("{} ({})", entry.rel_path, entry.size_bytes));
}
if looks_like_seed_data(&entry.rel_path, entry.size_bytes) {
seed_data.push(entry.rel_path.clone());
}
if let Some(engine) = initial_data_engine_for_rel_path(layout, &entry.rel_path) {
let mut report =
review_initial_data_sql(engine, &entry.rel_path, entry.text().unwrap_or_default());
if !entry.unreadable.is_empty() {
report.unreadable = entry.unreadable.clone();
warnings.push(format!(
"{}: initial data the safety review cannot read ({})",
entry.rel_path, entry.unreadable
));
}
database_initialization.push(report);
}
if options.publish_mode && looks_like_private_publish_path(&entry.rel_path) {
warnings.push(format!(
"{} looks like private/runtime data",
entry.rel_path
));
}
}
let mut by_capability: BTreeMap<NodeCapability, BTreeSet<String>> = BTreeMap::new();
for kind in &findings.nodes_used {
match capabilities_by_kind.get(kind) {
Some(capabilities) => {
for capability in capabilities {
by_capability
.entry(*capability)
.or_default()
.insert(kind.clone());
}
}
None => {
unresolved_kinds.insert(kind.clone());
}
}
}
if findings
.nodes_used
.contains(crate::pipeline::nodes::basic::trigger::webhook::NODE_KIND)
{
findings
.public_endpoints
.insert("webhook trigger".to_string());
}
if findings
.nodes_used
.contains(crate::pipeline::nodes::basic::trigger::schedule::NODE_KIND)
{
findings.schedules.insert("schedule trigger".to_string());
}
let capability_nodes = |capability: NodeCapability| -> Vec<String> {
by_capability
.get(&capability)
.map(|kinds| kinds.iter().cloned().collect())
.unwrap_or_default()
};
let database_effects = capability_nodes(NodeCapability::Database);
let filesystem_effects = capability_nodes(NodeCapability::Filesystem);
let network_effects = capability_nodes(NodeCapability::Network);
let code_execution = capability_nodes(NodeCapability::Process);
let credential_nodes = capability_nodes(NodeCapability::Credential);
if !credential_nodes.is_empty() {
warnings.push(format!(
"can read a stored credential, depending on how it is configured: {}",
credential_nodes.join(", ")
));
}
if !unresolved_kinds.is_empty() {
warnings.push(format!(
"the safety review cannot say what these nodes do, because nothing in this package \
provides them: {}",
unresolved_kinds
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
warnings.sort();
warnings.dedup();
violations.sort();
violations.dedup();
database_initialization.sort_by(|a, b| a.source.cmp(&b.source));
let mut risk_score = 0;
if !findings.credentials_required.is_empty() || !credential_nodes.is_empty() {
risk_score += 1;
}
if !findings.external_urls.is_empty() || !network_effects.is_empty() {
risk_score += 1;
}
if !database_effects.is_empty() {
risk_score += 2;
}
if !filesystem_effects.is_empty() {
risk_score += 1;
}
if !code_execution.is_empty() {
risk_score += 1;
}
if !findings.public_endpoints.is_empty() || !findings.schedules.is_empty() {
risk_score += 2;
}
if !large_files.is_empty() || !seed_data.is_empty() {
risk_score += 1;
}
if options.publish_mode && !warnings.is_empty() {
risk_score += 1;
}
PackageSafetyReview {
nodes_used: findings.nodes_used.into_iter().collect(),
credentials_required: findings.credentials_required.into_iter().collect(),
external_urls: findings.external_urls.into_iter().collect(),
database_effects,
filesystem_effects,
network_effects,
code_execution,
public_endpoints: findings.public_endpoints.into_iter().collect(),
schedules: findings.schedules.into_iter().collect(),
large_files,
seed_data,
database_initialization,
warnings,
risk_level: if violations.is_empty() {
PolicyRiskLevel::from_score(risk_score)
} else {
PolicyRiskLevel::Blocked
}
.as_str()
.to_string(),
violations,
}
}
struct CarriedNodeBundle {
manifest_path: String,
root: String,
package: MultiNodePackageDefinition,
}
impl CarriedNodeBundle {
fn entry_path(&self, rel_path: &str) -> String {
format!("{}{}", self.root, rel_path)
}
fn is_function_path(&self, entry_rel_path: &str) -> bool {
self.package
.functions
.values()
.any(|rel_path| self.entry_path(rel_path) == entry_rel_path)
}
fn function_graph(&self, entries: &[PackagePolicyEntry], rel_path: &str) -> Option<Value> {
let wanted = self.entry_path(rel_path);
let entry = entries.iter().find(|entry| entry.rel_path == wanted)?;
serde_json::from_str(entry.text()?).ok()
}
}
fn carried_node_bundles(entries: &[PackagePolicyEntry]) -> Vec<CarriedNodeBundle> {
entries
.iter()
.filter(|entry| {
entry
.rel_path
.rsplit('/')
.next()
.is_some_and(|name| name == "definition.json")
})
.filter_map(|entry| {
let document = decode_node_bundle(entry.text()?.as_bytes()).ok()?;
Some(CarriedNodeBundle {
manifest_path: entry.rel_path.clone(),
root: entry.rel_path[..entry.rel_path.len() - "definition.json".len()].to_string(),
package: document.spec,
})
})
.collect()
}
fn is_reviewed_pipeline_path(layout: &ResolvedProjectLayout, rel_path: &str) -> bool {
layout.is_pipeline_rel_path(rel_path)
}
impl PackageSafetyReview {
pub fn is_installable(&self) -> bool {
self.violations.is_empty()
}
}
fn looks_like_seed_data(rel_path: &str, size_bytes: usize) -> bool {
let path = rel_path.to_ascii_lowercase();
if path.contains("/seed") || path.contains("/sample") || path.contains("/demo") {
return true;
}
matches!(
Path::new(rel_path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"csv" | "jsonl" | "sqlite" | "db" | "parquet"
) && size_bytes > 0
}
fn looks_like_private_publish_path(path: &str) -> bool {
let lower = path.trim().to_ascii_lowercase();
lower.starts_with(".git/")
|| lower.starts_with(".env")
|| lower.contains("/.env")
|| lower.contains("secret")
|| lower.contains("credential")
|| lower.starts_with("data/")
|| lower.starts_with("runtime/")
|| lower.starts_with("target/")
|| lower.ends_with(".db")
|| lower.ends_with(".sqlite")
|| lower.ends_with(".sqlite3")
}
#[derive(Default)]
struct PipelineFindings {
nodes_used: BTreeSet<String>,
credentials_required: BTreeSet<String>,
external_urls: BTreeSet<String>,
public_endpoints: BTreeSet<String>,
schedules: BTreeSet<String>,
}
impl PipelineFindings {
fn read_pipeline(&mut self, source: &str, warnings: &mut Vec<String>) {
let Ok(value) = serde_json::from_str::<Value>(source) else {
warnings.push("One pipeline could not be parsed for safety review".to_string());
return;
};
self.nodes_used.extend(node_kinds_in_pipeline(&value));
self.read_value(&value);
}
fn read_value(&mut self, value: &Value) {
match value {
Value::Object(map) => {
for (key, child) in map {
let key_lc = key.to_ascii_lowercase();
if key_lc.contains("credential")
&& let Some(value) = child.as_str().filter(|s| !s.trim().is_empty())
{
self.credentials_required.insert(value.trim().to_string());
}
if matches!(key_lc.as_str(), "url" | "base_url" | "endpoint" | "host")
&& let Some(value) = child.as_str()
{
collect_urls_from_text(value, &mut self.external_urls);
}
if matches!(key_lc.as_str(), "path" | "route")
&& let Some(value) = child.as_str().filter(|s| s.starts_with('/'))
{
self.public_endpoints.insert(value.to_string());
}
if (key_lc.contains("cron") || key_lc.contains("schedule"))
&& let Some(value) = child.as_str().filter(|s| !s.trim().is_empty())
{
self.schedules.insert(value.trim().to_string());
}
self.read_value(child);
}
}
Value::Array(items) => {
for item in items {
self.read_value(item);
}
}
Value::String(text) => collect_urls_from_text(text, &mut self.external_urls),
_ => {}
}
}
}
fn collect_urls_from_text(text: &str, out: &mut BTreeSet<String>) {
for part in text.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ')' | '(' | ','))
{
let trimmed = part
.trim_matches(|c: char| matches!(c, '"' | '\'' | ')' | '(' | ',' | ';'))
.trim();
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
out.insert(trimmed.to_string());
}
}
}
pub const INITIAL_DATA_DIRS: &[(&str, &str)] = &[
("initial-data/sekejap", "sekejap"),
("initial-data/sqlite", "sqlite"),
("init/sekejap", "sekejap"),
("init/sqlite", "sqlite"),
("seeds/sekejap", "sekejap"),
("seeds/sqlite", "sqlite"),
];
pub fn initial_data_engine_for_rel_path<'a>(
layout: &'a ResolvedProjectLayout,
rel_path: &str,
) -> Option<&'a str> {
let rel = rel_path
.trim()
.trim_start_matches('/')
.trim_start_matches("./");
layout.initial_data_engine(rel)
}
pub fn split_initial_data_sql(sql: &str) -> Vec<String> {
let uncommented = sql
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
uncommented
.split(';')
.map(str::trim)
.filter(|stmt| !stmt.is_empty())
.map(|stmt| format!("{stmt};"))
.collect()
}
const OTHER_STATEMENT_KIND: &str = "OTHER";
const DESTRUCTIVE_QUOTE_CHARS: usize = 160;
pub fn review_initial_data_sql(
engine: &str,
source: &str,
sql: &str,
) -> DatabaseInitializationReport {
let (store, existing_data_at_risk) = initial_data_store_note(engine);
let mut statements: BTreeMap<String, usize> = BTreeMap::new();
let mut tables = BTreeSet::new();
let mut destructive = Vec::new();
for statement in split_initial_data_sql(sql) {
let facts = classify_sql_statement(&statement);
*statements.entry(facts.kind.to_string()).or_insert(0) += 1;
if let Some(table) = facts.table {
tables.insert(table);
}
if facts.destructive {
destructive.push(quote_sql_statement(&statement));
}
}
DatabaseInitializationReport {
engine: engine.to_string(),
source: source.to_string(),
store: store.to_string(),
existing_data_at_risk,
statements,
tables: tables.into_iter().collect(),
destructive,
unreadable: String::new(),
}
}
fn initial_data_store_note(engine: &str) -> (&'static str, bool) {
match engine {
"sekejap" | "sqlite" => ("created by this install", false),
_ => ("not described by this build", true),
}
}
struct SqlStatementFacts {
kind: &'static str,
table: Option<String>,
destructive: bool,
}
fn classify_sql_statement(statement: &str) -> SqlStatementFacts {
let body = strip_leading_sql_noise(statement);
let Some((verb, rest)) = take_keyword(body) else {
return SqlStatementFacts {
kind: OTHER_STATEMENT_KIND,
table: None,
destructive: false,
};
};
let destructive = matches!(verb.as_str(), "DROP" | "DELETE" | "TRUNCATE" | "ALTER");
let (kind, table) = match verb.as_str() {
"INSERT" => keyword_then_identifier(rest, "INTO", "INSERT INTO"),
"ALTER" => keyword_then_identifier(rest, "TABLE", "ALTER TABLE"),
"DELETE" => keyword_then_identifier(rest, "FROM", "DELETE FROM"),
"DROP" => keyword_then_identifier(rest, "TABLE", "DROP TABLE"),
"UPDATE" => ("UPDATE", read_identifier(rest)),
"TRUNCATE" => {
let rest = take_expected(rest, "TABLE").unwrap_or(rest);
("TRUNCATE", read_identifier(rest))
}
"CREATE" => classify_create_statement(rest),
_ => (OTHER_STATEMENT_KIND, None),
};
SqlStatementFacts {
kind,
table,
destructive,
}
}
fn keyword_then_identifier(
rest: &str,
keyword: &str,
kind: &'static str,
) -> (&'static str, Option<String>) {
match take_expected(rest, keyword) {
Some(rest) => (kind, read_identifier(skip_existence_clause(rest))),
None => (OTHER_STATEMENT_KIND, None),
}
}
fn classify_create_statement(rest: &str) -> (&'static str, Option<String>) {
let mut rest = rest;
while let Some((word, after)) = take_keyword(rest) {
if matches!(
word.as_str(),
"UNIQUE" | "TEMP" | "TEMPORARY" | "VIRTUAL" | "OR" | "REPLACE"
) {
rest = after;
continue;
}
break;
}
let Some((word, after)) = take_keyword(rest) else {
return (OTHER_STATEMENT_KIND, None);
};
match word.as_str() {
"TABLE" => (
"CREATE TABLE",
read_identifier(skip_existence_clause(after)),
),
"INDEX" => {
let after = skip_existence_clause(after);
let after = match take_keyword(after) {
Some((word, _)) if word == "ON" => after,
_ => skip_identifier(after),
};
(
"CREATE INDEX",
take_expected(after, "ON").and_then(read_identifier),
)
}
_ => (OTHER_STATEMENT_KIND, None),
}
}
fn skip_existence_clause(rest: &str) -> &str {
let Some(after_if) = take_expected(rest, "IF") else {
return rest;
};
let after_not = take_expected(after_if, "NOT").unwrap_or(after_if);
take_expected(after_not, "EXISTS").unwrap_or(rest)
}
fn strip_leading_sql_noise(statement: &str) -> &str {
let mut rest = statement.trim_start();
loop {
if let Some(after) = rest.strip_prefix("--") {
rest = after
.find('\n')
.map(|idx| &after[idx + 1..])
.unwrap_or("")
.trim_start();
continue;
}
if let Some(after) = rest.strip_prefix("/*") {
rest = after
.find("*/")
.map(|idx| &after[idx + 2..])
.unwrap_or("")
.trim_start();
continue;
}
return rest;
}
}
fn take_keyword(input: &str) -> Option<(String, &str)> {
let rest = input.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
(end > 0).then(|| (rest[..end].to_ascii_uppercase(), &rest[end..]))
}
fn take_expected<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
let (word, rest) = take_keyword(input)?;
(word == keyword).then_some(rest)
}
fn take_identifier(input: &str) -> Option<(String, &str)> {
let rest = input.trim_start();
let closing = match rest.chars().next()? {
'"' => Some('"'),
'`' => Some('`'),
'[' => Some(']'),
_ => None,
};
if let Some(closing) = closing {
let body = &rest[1..];
let end = body.find(closing)?;
let name = body[..end].trim().to_string();
return (!name.is_empty()).then_some((name, &body[end + closing.len_utf8()..]));
}
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.')))
.unwrap_or(rest.len());
let name = rest[..end]
.trim_matches('.')
.rsplit('.')
.next()
.unwrap_or_default()
.to_string();
(!name.is_empty()).then_some((name, &rest[end..]))
}
fn read_identifier(input: &str) -> Option<String> {
take_identifier(input).map(|(name, _)| name)
}
fn skip_identifier(input: &str) -> &str {
take_identifier(input)
.map(|(_, rest)| rest)
.unwrap_or(input)
}
fn quote_sql_statement(statement: &str) -> String {
let collapsed = strip_leading_sql_noise(statement)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let trimmed = collapsed.trim_end_matches(';').trim_end();
if trimmed.chars().count() <= DESTRUCTIVE_QUOTE_CHARS {
return trimmed.to_string();
}
let head = trimmed
.chars()
.take(DESTRUCTIVE_QUOTE_CHARS)
.collect::<String>();
format!("{}...", head.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::model::ZebflowJsonLayout;
const MIXED_SEED: &str = r#"
-- Blog seed data.
DROP TABLE IF EXISTS posts;
CREATE TABLE posts (_key TEXT PRIMARY KEY, slug TEXT, body TEXT);
create table if not exists `authors` (_key TEXT PRIMARY KEY, name TEXT);
CREATE UNIQUE INDEX posts_slug ON main.posts (slug);
INSERT INTO posts (_key, slug, body) VALUES ('a', 'hello', 'world');
insert into "authors" (_key, name) VALUES ('b', 'Ada');
ALTER TABLE posts ADD COLUMN tags TEXT;
UPDATE posts SET body = 'edited' WHERE _key = 'a';
DELETE FROM authors WHERE _key = 'nobody';
TRUNCATE TABLE tags;
"#;
fn seed_entry(rel_path: &str, sql: &str) -> PackagePolicyEntry {
PackagePolicyEntry {
rel_path: rel_path.to_string(),
kind: "initial data".to_string(),
size_bytes: sql.len(),
content: sql.to_string(),
unreadable: String::new(),
}
}
fn only_report(entries: &[PackagePolicyEntry]) -> DatabaseInitializationReport {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
entries,
Vec::new(),
PackageReviewOptions::default(),
);
assert_eq!(
review.database_initialization.len(),
1,
"one seed file, one row"
);
review.database_initialization[0].clone()
}
#[test]
fn a_mixed_seed_file_reports_its_statements_tables_and_destructive_work() {
let report = only_report(&[seed_entry("seeds/sekejap/002-posts.sql", MIXED_SEED)]);
assert_eq!(report.engine, "sekejap");
assert_eq!(report.source, "seeds/sekejap/002-posts.sql");
assert_eq!(
report.statements,
BTreeMap::from([
("DROP TABLE".to_string(), 1),
("CREATE TABLE".to_string(), 2),
("CREATE INDEX".to_string(), 1),
("INSERT INTO".to_string(), 2),
("ALTER TABLE".to_string(), 1),
("UPDATE".to_string(), 1),
("DELETE FROM".to_string(), 1),
("TRUNCATE".to_string(), 1),
])
);
assert_eq!(report.tables, vec!["authors", "posts", "tags"]);
assert_eq!(
report.destructive,
vec![
"DROP TABLE IF EXISTS posts",
"ALTER TABLE posts ADD COLUMN tags TEXT",
"DELETE FROM authors WHERE _key = 'nobody'",
"TRUNCATE TABLE tags",
]
);
}
#[test]
fn a_seed_file_says_no_existing_database_is_reachable() {
for (path, engine) in [
("seeds/sekejap/002-posts.sql", "sekejap"),
("init/sqlite/001-schema.sql", "sqlite"),
] {
let report = only_report(&[seed_entry(path, "INSERT INTO posts VALUES ('a');")]);
assert_eq!(report.engine, engine);
assert_eq!(report.store, "created by this install");
assert!(!report.existing_data_at_risk);
}
}
#[test]
fn an_unrecognised_statement_is_counted_not_dropped() {
let sql = "INSERT INTO posts VALUES ('a');\n\
PRAGMA journal_mode = WAL;\n\
GRANT ALL ON posts TO nobody;\n\
VACUUM;";
let report = only_report(&[seed_entry("seeds/sqlite/003-odd.sql", sql)]);
assert_eq!(report.statements.get("OTHER"), Some(&3));
assert_eq!(
report.statements.values().sum::<usize>(),
split_initial_data_sql(sql).len(),
"every statement the installer runs is counted exactly once"
);
}
#[test]
fn only_the_paths_the_installer_replays_are_reported() {
let layout = ResolvedProjectLayout::platform_default();
for path in [
"docs/schema.sql",
"seeds/postgres/001.sql",
"seeds/sekejap/notes.md",
] {
assert_eq!(
initial_data_engine_for_rel_path(&layout, path),
None,
"{path}"
);
}
assert_eq!(
initial_data_engine_for_rel_path(&layout, "initial-data/sqlite/001.sql"),
Some("sqlite")
);
}
#[test]
fn a_seed_file_that_cannot_be_read_still_gets_a_row() {
let mut entry = seed_entry("seeds/sekejap/002-posts.sql", "");
entry.unreadable = "artifact not found on this channel".to_string();
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[entry],
Vec::new(),
PackageReviewOptions::default(),
);
let report = &review.database_initialization[0];
assert_eq!(report.unreadable, "artifact not found on this channel");
assert!(report.statements.is_empty());
assert!(
review
.warnings
.iter()
.any(|warning| warning.contains("seeds/sekejap/002-posts.sql")),
"the file is named in the warnings: {:?}",
review.warnings
);
}
#[test]
fn a_long_destructive_statement_is_quoted_back_and_cut() {
let keys = (0..80)
.map(|index| format!("'key-{index}'"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!("DELETE FROM posts WHERE _key IN ({keys});");
let report = only_report(&[seed_entry("seeds/sqlite/004-purge.sql", &sql)]);
let quoted = &report.destructive[0];
assert!(quoted.starts_with("DELETE FROM posts WHERE _key IN ("));
assert!(quoted.ends_with("..."));
assert!(quoted.chars().count() <= DESTRUCTIVE_QUOTE_CHARS + 3);
}
fn entry(rel_path: &str) -> PackagePolicyEntry {
PackagePolicyEntry {
rel_path: rel_path.to_string(),
kind: "file".to_string(),
size_bytes: 1,
content: String::new(),
unreadable: String::new(),
}
}
fn review_with(layout: &ResolvedProjectLayout, paths: &[&str]) -> PackageSafetyReview {
let entries = paths.iter().map(|path| entry(path)).collect::<Vec<_>>();
review_package_entries(
layout,
&entries,
Vec::new(),
PackageReviewOptions::default(),
)
}
#[test]
fn a_package_carrying_an_executable_file_type_is_refused() {
let review = review_with(
&ResolvedProjectLayout::platform_default(),
&[
"pipelines/hub/pkg/install.sh",
"pipelines/hub/pkg/libnode.dylib",
],
);
assert!(!review.is_installable());
assert_eq!(review.risk_level, "blocked");
assert_eq!(
review.violations,
vec![
"pipelines/hub/pkg/install.sh: extension '.sh' is not a file type a package may \
install"
.to_string(),
"pipelines/hub/pkg/libnode.dylib: extension '.dylib' is not a file type a package \
may install"
.to_string(),
]
);
}
#[test]
fn ordinary_project_content_installs_unchanged() {
let review = review_with(
&ResolvedProjectLayout::platform_default(),
&[
"pipelines/api/users-list.zf.json",
"pipelines/pages/hello.tsx",
"pipelines/pages/demo/deck-utils.ts",
"pipelines/styles/main.css",
"pipelines/assets/favicon-16x16.png",
"pipelines/shared/ui/.gitkeep",
"libraries/zeb/deckgl/0.1/runtime/deckgl.bundle.mjs",
"libraries.lock.json",
"docs/readme.md",
"schemas/sekejap/schema.json",
"seeds/sekejap/001-posts.sql",
"zebflow.yaml",
"zeb.lock",
"zebflow.init.json",
],
);
assert!(review.violations.is_empty(), "{:?}", review.violations);
assert!(review.is_installable());
}
#[test]
fn conventional_package_notices_install_only_as_readable_text() {
let layout = ResolvedProjectLayout::platform_default();
let notice = b"Copyright contributors\nPermission is granted.\n";
for name in [
"LICENSE",
"LICENSE.marked",
"LICENSE.dompurify",
"MODIFICATIONS",
] {
let entries = [PackagePolicyEntry::from_bytes(
format!("library/{name}"),
"file",
notice.len(),
notice,
)];
for publish_mode in [false, true] {
let review = review_package_entries(
&layout,
&entries,
Vec::new(),
PackageReviewOptions {
publish_mode,
..Default::default()
},
);
assert!(review.is_installable(), "{name}: {:?}", review.violations);
}
}
for bytes in [
b"".as_slice(),
b"\xff\xfe".as_slice(),
b"text\0binary".as_slice(),
] {
let review = review_package_entries(
&layout,
&[PackagePolicyEntry::from_bytes(
"LICENSE",
"file",
bytes.len(),
bytes,
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert!(!review.is_installable(), "a notice must be readable text");
}
let unresolved = review_package_entries(
&layout,
&[PackagePolicyEntry::unresolved(
"LICENSE",
"file",
20,
"artifact unavailable",
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert!(!unresolved.is_installable());
for refused in [
"LICENSE.exe",
"LICENSE.sh",
"LICENSE.unrecognized",
"MODIFICATIONS.exe",
"Makefile",
".env",
] {
assert!(
!review_with(&layout, &[refused]).is_installable(),
"{refused}"
);
}
}
#[test]
fn a_file_with_no_extension_is_refused() {
let review = review_with(
&ResolvedProjectLayout::platform_default(),
&["pipelines/hub/pkg/.env", "pipelines/hub/pkg/Makefile"],
);
assert_eq!(
review.violations,
vec![
"pipelines/hub/pkg/.env: a name with no extension is not a file type a package \
may install"
.to_string(),
"pipelines/hub/pkg/Makefile: a name with no extension is not a file type a \
package may install"
.to_string(),
]
);
}
#[test]
fn a_project_that_narrows_its_extensions_refuses_what_it_excluded() {
let layout = ZebflowJsonLayout {
allowed_extensions: Some(vec!["tsx".to_string(), "json".to_string()]),
..ZebflowJsonLayout::default()
}
.resolve();
let review = review_with(
&layout,
&[
"pipelines/feed.zf.json",
"pipelines/pages/hello.tsx",
"pipelines/styles/main.css",
"pipelines/assets/logo.png",
"zebflow.yaml",
"zeb.lock",
"pipelines/shared/ui/.gitkeep",
],
);
assert_eq!(
review.violations,
vec![
"pipelines/assets/logo.png: extension '.png' is not a file type a package may \
install"
.to_string(),
"pipelines/styles/main.css: extension '.css' is not a file type a package may \
install"
.to_string(),
]
);
}
fn pipeline_entry(rel_path: &str, kinds: &[&str]) -> PackagePolicyEntry {
let nodes = kinds
.iter()
.enumerate()
.map(|(index, kind)| {
serde_json::json!({"id": format!("n{index}"), "kind": kind, "config": {}})
})
.collect::<Vec<_>>();
let source = serde_json::json!({
"apiVersion": "zebflow.com/v1",
"kind": "Pipeline",
"metadata": {"name": "demo"},
"spec": {"id": "demo", "nodes": nodes, "edges": []}
})
.to_string();
PackagePolicyEntry {
rel_path: rel_path.to_string(),
kind: "pipeline".to_string(),
size_bytes: source.len(),
content: source,
unreadable: String::new(),
}
}
fn bundle_entry(rel_path: &str, spec: Value) -> PackagePolicyEntry {
let spec: crate::platform::model::MultiNodePackageDefinition =
serde_json::from_value(spec).expect("bundle spec");
let mut metadata = crate::contracts::ContractMetadata::named(&spec.package);
metadata.version = Some(spec.version.clone());
let bytes = crate::contracts::kinds::encode_node_bundle(metadata, spec)
.expect("encode node bundle");
PackagePolicyEntry {
rel_path: rel_path.to_string(),
kind: "node bundle".to_string(),
size_bytes: bytes.len(),
content: String::from_utf8(bytes).expect("bundle is utf-8"),
unreadable: String::new(),
}
}
#[test]
fn the_effects_the_old_matcher_caught_are_still_caught() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[pipeline_entry(
"pipelines/demo.zf.json",
&[
"trigger.webhook",
"http.response.fetch",
"postgres.query.run",
"fs.file.put",
],
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert!(review.database_effects.contains(&"postgres.query.run".to_string()));
assert!(review.filesystem_effects.contains(&"fs.file.put".to_string()));
assert!(review.nodes_used.contains(&"http.response.fetch".to_string()));
assert_eq!(review.risk_level, "high");
assert!(review.violations.is_empty(), "{:?}", review.violations);
}
#[test]
fn a_trigger_reports_its_route_without_a_route_written_down() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[pipeline_entry(
"pipelines/demo.zf.json",
&["trigger.webhook", "trigger.schedule"],
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert_eq!(review.public_endpoints, vec!["webhook trigger".to_string()]);
assert_eq!(review.schedules, vec!["schedule trigger".to_string()]);
}
#[test]
fn a_capability_no_name_would_reveal_is_reported() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[pipeline_entry(
"pipelines/demo.zf.json",
&["browser.page.run", "fs.archive.create", "logic.collect"],
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert_eq!(review.network_effects, vec!["browser.page.run".to_string()]);
assert_eq!(
review.code_execution,
vec!["browser.page.run".to_string(), "fs.archive.create".to_string()]
);
assert!(
!review
.filesystem_effects
.contains(&"logic.collect".to_string())
);
assert!(
review
.warnings
.iter()
.any(|warning| warning.starts_with("can read a stored credential")),
"{:?}",
review.warnings
);
assert!(
review.credentials_required.is_empty(),
"no credential is configured, so none may be reported as required: {:?}",
review.credentials_required
);
}
#[test]
fn a_node_kind_this_package_cannot_account_for_is_reported() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[pipeline_entry(
"pipelines/demo.zf.json",
&["x.nowhere.thing.run", "fs.file.put"],
)],
Vec::new(),
PackageReviewOptions::default(),
);
assert!(
review
.warnings
.iter()
.any(|warning| warning.contains("x.nowhere.thing.run")),
"{:?}",
review.warnings
);
assert!(review.is_installable());
}
#[test]
fn a_node_bundles_function_pipelines_are_reviewed_like_any_other() {
let entries = vec![
bundle_entry(
"definition.json",
serde_json::json!({
"package": "acme",
"version": "1.0.0",
"title": "Acme",
"description": "A bundle whose function reaches the network.",
"functions": {"main": "functions/main.zf.json"},
"nodes": [{
"kind": "x.acme.record.sync",
"title": "Sync",
"description": "Push a payload somewhere.",
"run": {"function": "main"},
"definition": {
"input_pins": ["in"],
"output_pins": ["out"],
"config_schema": {},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"}
}
}]
}),
),
pipeline_entry(
"functions/main.zf.json",
&["trigger.function", "http.response.fetch"],
),
pipeline_entry("pipelines/uses-it.zf.json", &["x.acme.record.sync"]),
];
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&entries,
Vec::new(),
PackageReviewOptions {
bundle_internal_paths: true,
..PackageReviewOptions::default()
},
);
assert!(review.violations.is_empty(), "{:?}", review.violations);
assert!(
review
.network_effects
.contains(&"x.acme.record.sync".to_string()),
"{:?}",
review.network_effects
);
assert!(
review
.network_effects
.contains(&"http.response.fetch".to_string())
);
assert!(
!review
.warnings
.iter()
.any(|warning| warning.contains("cannot say what these nodes do")),
"the bundle accounts for its own node: {:?}",
review.warnings
);
}
#[test]
fn a_bundle_claiming_a_kind_this_build_provides_is_refused() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[bundle_entry(
"definition.json",
serde_json::json!({
"package": "acme",
"version": "1.0.0",
"title": "Acme",
"description": "A bundle shadowing a native node.",
"functions": {"main": "functions/main.zf.json"},
"nodes": [{
"kind": "http.response.fetch",
"title": "HTTP Request",
"description": "Not the one you think.",
"run": {"function": "main"},
"definition": {
"input_pins": ["in"],
"output_pins": ["out"],
"config_schema": {},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"}
}
}]
}),
)],
Vec::new(),
PackageReviewOptions {
bundle_internal_paths: true,
..PackageReviewOptions::default()
},
);
assert!(!review.is_installable());
assert_eq!(review.risk_level, "blocked");
assert_eq!(
review.violations,
vec![
"definition.json: declares node kind 'http.response.fetch', which this build already \
provides"
.to_string()
]
);
}
#[test]
fn a_bundle_in_its_own_namespace_is_not_refused() {
let review = review_package_entries(
&ResolvedProjectLayout::platform_default(),
&[bundle_entry(
"definition.json",
serde_json::json!({
"package": "acme",
"version": "1.0.0",
"title": "Acme",
"description": "A bundle that owns the kind it declares.",
"functions": {"main": "functions/main.zf.json"},
"nodes": [{
"kind": "x.acme.request.send",
"title": "Acme Request",
"description": "Its own node.",
"run": {"function": "main"},
"definition": {
"input_pins": ["in"],
"output_pins": ["out"],
"config_schema": {},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"}
}
}]
}),
)],
Vec::new(),
PackageReviewOptions {
bundle_internal_paths: true,
..PackageReviewOptions::default()
},
);
assert!(review.violations.is_empty(), "{:?}", review.violations);
}
#[test]
fn a_node_bundle_is_judged_by_its_own_contract_not_the_repository_set() {
let layout = ZebflowJsonLayout {
allowed_extensions: Some(vec!["tsx".to_string()]),
..ZebflowJsonLayout::default()
}
.resolve();
let entries = ["definition.json", "icon.svg", "wasm/core.wasm"]
.iter()
.map(|path| entry(path))
.collect::<Vec<_>>();
let review = review_package_entries(
&layout,
&entries,
Vec::new(),
PackageReviewOptions {
bundle_internal_paths: true,
..PackageReviewOptions::default()
},
);
assert!(review.violations.is_empty(), "{:?}", review.violations);
}
}