Files
PiCloud/crates/shared/src/script.rs
MechaCat02 b53eabb786 feat(modules): lexical import resolver — seal imports to defining node (Phase 4b C3)
Make `import` resolution lexical (§5.5): an inherited group script's
imports resolve against the module set at its OWN defining node, walking
up from there — a leaf app can't shadow them, and a group script behaves
identically under every inheriting app.

Mechanism — Rhai's `_source` carries the importing AST's origin tag:
- The entry script has no source tag → resolve from `default_origin` (its
  defining node, threaded in via C2).
- Each resolved module's AST source is set to `encode(module.owner())`
  before `eval_ast_as_new`, so its nested `import`s carry the module's own
  node as `_source` and resolve from there — lexical chaining.
- `encode_origin`/`decode_origin` map a `ScriptOwner` to/from `app:<uuid>`
  / `group:<uuid>`.

Cache rekeyed from `(app_id, name)` to the resolved `ScriptId`: a compiled
module is lexically self-contained, so its body is a pure function of
`(script_id, updated_at)` — this also dedupes a shared group module across
inheriting apps and keeps same-named cross-app modules distinct.

Adds two executor-core unit tests (origin-keyed fake) proving the trust
boundary: a group module's import binds the group's module even when an
app-owned module of the same name exists; `ScriptOwner` gains `Hash`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:25 +02:00

183 lines
6.7 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
/// Semantic role of a script (v1.1.3).
///
/// `Endpoint` scripts have an executable entry point — they bind to HTTP
/// routes and act as trigger handlers. `Module` scripts are libraries of
/// `fn`/`const` declarations imported by other scripts via Rhai's
/// `import "<name>" as <alias>;` syntax. Modules cannot be invoked
/// directly: route binding and trigger creation reject `Module` targets.
///
/// Serialized as `"endpoint"` / `"module"` so the wire shape is the
/// same string the SQL `CHECK (kind IN ('endpoint','module'))`
/// constraint enforces.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScriptKind {
#[default]
Endpoint,
Module,
}
impl ScriptKind {
/// Wire / SQL representation. Inverse of `parse_str`.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Endpoint => "endpoint",
Self::Module => "module",
}
}
/// Parse the canonical wire / SQL form. Returns `None` for any
/// other input; callers map that to a 400 / `ValidationError`.
/// Named `parse_str` (not `from_str`) to dodge the
/// `std::str::FromStr` lint without taking on the trait's
/// `Result<Self, Self::Err>` shape that this caller doesn't need.
#[must_use]
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"endpoint" => Some(Self::Endpoint),
"module" => Some(Self::Module),
_ => None,
}
}
}
#[cfg(test)]
mod kind_tests {
use super::*;
#[test]
fn default_is_endpoint() {
assert_eq!(ScriptKind::default(), ScriptKind::Endpoint);
}
#[test]
fn round_trips_through_serde_lowercase() {
assert_eq!(
serde_json::to_string(&ScriptKind::Endpoint).unwrap(),
"\"endpoint\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Module).unwrap(),
"\"module\""
);
assert_eq!(
serde_json::from_str::<ScriptKind>("\"endpoint\"").unwrap(),
ScriptKind::Endpoint
);
assert_eq!(
serde_json::from_str::<ScriptKind>("\"module\"").unwrap(),
ScriptKind::Module
);
}
#[test]
fn parse_str_round_trip() {
for k in [ScriptKind::Endpoint, ScriptKind::Module] {
assert_eq!(ScriptKind::parse_str(k.as_str()), Some(k));
}
assert_eq!(ScriptKind::parse_str("invalid"), None);
assert_eq!(ScriptKind::parse_str(""), None);
}
}
/// A user-uploaded Rhai script and its execution configuration.
///
/// This is the canonical representation that flows between manager (storage),
/// orchestrator (dispatch), and executor (run). It must stay serializable
/// because in cluster mode it crosses process boundaries on every invocation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: ScriptId,
/// Owning app, when app-owned. Set on create, immutable thereafter — a
/// "move to another app" is a copy+delete, not an in-place edit
/// (snapshot semantics — see blueprint §11.5).
///
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
/// inherited by every descendant app — it has no single app, so the
/// **execution context** app is supplied by the route/trigger/caller that
/// invoked it, never read off the script. Reading `app_id` to mean "the
/// app this runs under" is therefore a bug for group scripts; use the
/// invoking surface's app_id instead.
#[serde(default)]
pub app_id: Option<AppId>,
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
/// the same name shadows the inherited one).
#[serde(default)]
pub group_id: Option<GroupId>,
pub name: String,
pub description: Option<String>,
pub version: i32,
pub source: String,
/// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported)
/// or `Module` (v1.1.3 — imported by other scripts, never bound
/// directly to a route or trigger).
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: u32,
/// Per-script overrides for Rhai sandbox limits. Empty = platform
/// defaults. Values are admin-ceiling-clamped at write time.
#[serde(default)]
pub sandbox: ScriptSandbox,
/// **v1.3+ advisory only.** Real heap limits require OS-level
/// isolation (cgroups, process limits) which lands with cluster-mode
/// executor sandboxing. The field stays in the schema so we don't
/// have to add it back when that's built.
pub memory_limit_mb: u32,
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
/// script is not invocable (route 404s, trigger doesn't fire) but stays
/// as desired state (not pruned). Defaults `true`.
#[serde(default = "crate::default_true")]
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
/// in-memory witness of that invariant so call sites can `match` exhaustively
/// instead of juggling two `Option`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ScriptOwner {
App(AppId),
Group(GroupId),
}
impl Script {
/// The script's owner, reconstructed from the polymorphic columns.
/// A row that violates the exactly-one invariant (both/neither set —
/// impossible under the CHECK) is reported as whichever is present,
/// preferring the app, so a corrupt row never panics on the hot path.
#[must_use]
pub fn owner(&self) -> Option<ScriptOwner> {
match (self.app_id, self.group_id) {
(Some(a), _) => Some(ScriptOwner::App(a)),
(None, Some(g)) => Some(ScriptOwner::Group(g)),
(None, None) => None,
}
}
/// True iff this script is owned by `app` directly (not via a group).
/// The cheap, app-owned-only ownership check — group inheritance is
/// resolved separately (chain-membership), never by this method.
#[must_use]
pub fn is_owned_by_app(&self, app: AppId) -> bool {
self.app_id == Some(app)
}
}