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>
This commit is contained in:
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptOwner, ScriptSandbox, Services,
|
||||
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -609,3 +609,130 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
v.imports
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4b — lexical (origin-aware) resolution.
|
||||
//
|
||||
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for LexicalModuleSource {
|
||||
async fn resolve(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||
let mut r = req(app_id);
|
||||
r.script_owner = Some(owner);
|
||||
r
|
||||
}
|
||||
|
||||
/// A group module's `import` resolves from the GROUP (its own defining
|
||||
/// node), not the inheriting app — even when an app-owned module of the
|
||||
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||
/// a module an inherited group script depends on.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_nested_import_seals_to_module_owner() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
|
||||
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"outer",
|
||||
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "outer" as o; o::val()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!(42),
|
||||
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||
/// module — proving the fake distinguishes origins and the entry uses
|
||||
/// `default_origin`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_entry_origin_selects_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "inner" as i; i::v()"#,
|
||||
req_with_owner(app, ScriptOwner::App(app)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user