mirror of
https://github.com/logos-co/logos-rust-sdk.git
synced 2026-08-27 09:51:06 +00:00
fix(origin): a module announces its OWN name, not "core"
plugin.rs hardcoded `let origin = CString::new("core")` when building a
module's outbound client, so every Rust module announced itself as "core" —
a TokenManager::bootstrapKeys() ANCHOR name — unprompted, in every handshake.
That is not a misattribution bug. "core" is an anchor name, so on a pre-0.8
module the provider's saveToken("core", T) lands in the OUTBOUND namespace,
which is exactly where the credential check reads. An ordinary
capability-minted pair token therefore BECOMES the provider's own credential.
Driven end to end: the caller's currentCaller() at the victim reports
caller_kind=host — a module authorizing AS THE HOST — and the victim's own
anchor is destroyed, locking the host out of that module. It also silently
widened any access policy whose allowedCallers contained "core".
Why the name had to be baked at generation time: the SDK genuinely cannot
learn it at runtime. The module-impl C ABI declares no self-name export, and
set_context's instance_id is a per-INSTANCE id derived from the persistence
path — often absent entirely. It IS known to lidl-gen as module.name, the same
string the derived name() method already uses and the same fact the C++
umbrella already bakes. So lidl-gen emits LOGOS_MODULE_NAME and the generated
ensure_ready() latches it into a set-once OnceLock ahead of the install hook.
Unset yields an EMPTY string, never a guess. Empty is fail-closed by name at
two independent gates — capability_module::requestModule rejects an empty
module name, and ModuleProxy rejects an empty caller — so a module that
somehow reaches the wire unlatched is refused rather than silently wearing
somebody else's identity.
The client cache is keyed (origin, target) rather than target: a client
carries its origin for life, so one constructed before the latch can never be
handed back after it. The construction still happens OUTSIDE the map lock —
outbound_origin() reads the OnceLock before the lock is taken — because on a
Qt-affine transport lp_client_create ends in runOnQtMainThread and holding the
lock there deadlocks.
ipc-test now asserts the announced origin across process boundaries: what the
caller announced, what capability_module admitted, the key the provider was
told to file under, the negative that no module may announce core or
capability_module, and an anti-vacuity check that a handshake actually
happened.
Also defines logos_module_accept_inbound_token (protocol 0.8). The binding is
declared inside the guarded emitted block rather than in api.rs: sharing an
rlib object with save_token pulled an undefined lp_token_save_inbound into
EVERY Rust module at EVERY protocol version, which ipc-test caught as a
dlopen failure.
Requires logos-protocol fix/token-direction-key-namespace (59b27ef).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2b88750085
commit
3179ff3661
@@ -173,6 +173,33 @@ identity later copies it at the top. Requires **logos-protocol 0.6**: the host p
|
||||
across `logos_module_set_call_caller`, which the generated scaffold only exports at >= 0.6. Built
|
||||
against an older protocol the accessor exists and answers `Unknown`.
|
||||
|
||||
### Who you are — the announced origin
|
||||
|
||||
The mirror of `current_caller()`, from the other side. Every outbound call this module makes
|
||||
carries an **origin**: the name it declares itself to be. `capability_module` checks that name
|
||||
against its known-caller roster and against the target's access policy, then pushes the minted
|
||||
token to the target *naming it* — so the origin is the key the target files the token under, and
|
||||
the name the target's `current_caller()` reports.
|
||||
|
||||
You do not set it. The generated scaffold carries your contract's own module name as
|
||||
`LOGOS_MODULE_NAME` and declares it to the SDK from every C-ABI entry point, before any of your
|
||||
code runs — the same fact the C++ generated umbrella bakes into `logos::LpClient(target, origin)`.
|
||||
|
||||
It matters that the name is real. `"core"` and `"capability_module"` are not module names: they
|
||||
are `TokenManager::bootstrapKeys()` role labels, the keys a **host-issued anchor** lives under. A
|
||||
module announcing one of them authorizes as the host at every callee, and satisfies every derived
|
||||
access policy (which always lists `"core"` among its allowed callers).
|
||||
|
||||
Only a caller **outside** a module plugin — a Rust binary linking `liblogos_protocol` through
|
||||
`lib.callerBuildSupport`, with no generated scaffold — has to declare its own:
|
||||
|
||||
```rust
|
||||
logos_rust_sdk::set_module_origin("my_tool"); // before the first call
|
||||
```
|
||||
|
||||
Set once per process; a second, different name is refused. With none declared the origin is empty,
|
||||
which the capability handshake rejects by name — fail closed, never a borrowed identity.
|
||||
|
||||
## Supporting types
|
||||
|
||||
The handful of SDK types that surface directly in module code:
|
||||
|
||||
@@ -707,6 +707,66 @@ fn teardown_block(protocol_version: &str) -> String {
|
||||
/// version decides both. Protocol-first would instead turn this repo's ABI
|
||||
/// check (and logos-cpp-sdk's, and logos-module-builder's `nm` check) red the
|
||||
/// night the bump merged.
|
||||
/// The module-impl export `logos_module_accept_inbound_token`, added at
|
||||
/// protocol 0.8.
|
||||
///
|
||||
/// THE SAME TOTAL-ABI RULE as the three blocks above, one bump later.
|
||||
/// logos-protocol only DECLARES this (cpp/logos_module_impl.h); every language
|
||||
/// backend owes the definition, and logos-cpp-sdk's cdylib emitter gains one in
|
||||
/// the same wave. The Qt glue emits a DIRECT call to it from
|
||||
/// `informModuleToken` — no dlsym, no null check — so a scaffold that omits it
|
||||
/// links clean and then fails at `dlopen()` on ELF under nixpkgs'
|
||||
/// `-Wl,-z,now`, and is invisible on macOS. That is how 0.3, 0.5 and 0.6 each
|
||||
/// shipped broken, every time at perfect version agreement.
|
||||
///
|
||||
/// WHY IT IS A SECOND DOOR RATHER THAN A FLAG ON `logos_module_accept_token`.
|
||||
/// The two carry opposite directions. `accept_token` is OUTBOUND — the module's
|
||||
/// own host-issued anchor, seeded by the glue's `onInit`, the credential this
|
||||
/// module PRESENTS. This one is a CALLER's token, named by capability_module.
|
||||
/// While there was one door the caller's token went into the outbound cache and
|
||||
/// the module then presented a peer the very token that peer had been issued;
|
||||
/// measured as a rejection plus a full extra round trip on every call of every
|
||||
/// two-way pair, forever, reported to the caller as success. A parameter would
|
||||
/// have been a value a caller can get wrong and default; a separate name either
|
||||
/// resolves or does not.
|
||||
fn accept_inbound_token_block(protocol_version: &str) -> String {
|
||||
if !protocol_at_least(protocol_version, 0, 8) {
|
||||
return String::new();
|
||||
}
|
||||
// THE BINDING IS DECLARED HERE, NOT IN THE SDK CRATE, and that is a link
|
||||
// requirement rather than a style choice. A `pub fn` wrapper in
|
||||
// logos-rust-sdk's api.rs shares an rlib object with save_token, which every
|
||||
// module needs, so the linker pulls the object in and its undefined
|
||||
// lp_token_save_inbound reference with it -- for EVERY module, including
|
||||
// ones generated for protocol 0.7 that emit no call at all. Measured: every
|
||||
// Rust module then died at dlopen with "undefined symbol:
|
||||
// lp_token_save_inbound", on Linux only, macOS silently fine. Declaring it
|
||||
// inside the gated block is what keeps landing this ahead of the protocol
|
||||
// bump INERT, which is the property the tests below pin.
|
||||
//
|
||||
// Three lines of pure forwarding and no logic: the token-registry carve-out
|
||||
// (a granted registry ALSO gets the outbound entry, because for it the same
|
||||
// wire message means "here is X's token, present it when you call X") lives
|
||||
// in logos-protocol, where a unit test reaches it by value. This mirrors the
|
||||
// C++ backend, which likewise calls lp_token_save_inbound straight from
|
||||
// emitted text.
|
||||
//
|
||||
// Refuses NULL, unlike set_call_caller: NULL is not a value in THIS ABI.
|
||||
"\n\
|
||||
extern \"C\" {\n\
|
||||
\x20 fn lp_token_save_inbound(caller: *const c_char, token: *const c_char) -> c_int;\n\
|
||||
}\n\n\
|
||||
#[no_mangle]\n\
|
||||
pub extern \"C\" fn logos_module_accept_inbound_token(caller: *const c_char, token: *const c_char) -> c_int {\n\
|
||||
\x20 if caller.is_null() || token.is_null() { return -1; }\n\
|
||||
\x20 // INBOUND: `caller` is the module that will CALL US. This is not a\n\
|
||||
\x20 // credential this module may present to anyone, and it must not\n\
|
||||
\x20 // reach lp_token_save().\n\
|
||||
\x20 unsafe { lp_token_save_inbound(caller, token) }\n\
|
||||
}\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn set_call_caller_block(protocol_version: &str) -> String {
|
||||
if !protocol_at_least(protocol_version, 0, 6) {
|
||||
return String::new();
|
||||
@@ -789,6 +849,35 @@ pub fn generate_provider_with(
|
||||
module.name, trait_name
|
||||
));
|
||||
|
||||
// -- identity -----------------------------------------------------------
|
||||
//
|
||||
// THIS MODULE'S OWN NAME, from the contract, as a compile-time constant.
|
||||
//
|
||||
// The Rust SDK used to announce the literal "core" as the ORIGIN of every
|
||||
// outbound call it made — a `TokenManager::bootstrapKeys()` anchor label,
|
||||
// not a module name. Measured: `requestModule for origin: "core"`, the
|
||||
// target filing the minted token under "core", `currentCaller()` at the
|
||||
// target reporting the HOST, and every derived access policy (which always
|
||||
// lists "core" among its allowed callers) silently widened. The C++ SDK
|
||||
// never had it: its generated umbrella bakes metadata.json#name into
|
||||
// `logos::LpClient(target, origin)`.
|
||||
//
|
||||
// This is that bake. The name is not invented and not passed at runtime by
|
||||
// the host — the module-impl C ABI carries no self-name (set_context's
|
||||
// instance_id is a per-INSTANCE id, not the module's type name) — it is
|
||||
// `module.name` from the same contract every other identity in this file
|
||||
// comes from, including the derived `name()` method's literal.
|
||||
out.push_str(&format!(
|
||||
"/// This module's own name, from the LIDL contract.\n\
|
||||
///\n\
|
||||
/// The ORIGIN of every outbound call this image makes: it is what\n\
|
||||
/// capability_module checks against its known-caller roster and the\n\
|
||||
/// target's access policy, what the target files the minted token\n\
|
||||
/// under, and what a callee's `current_caller()` reports.\n\
|
||||
pub const LOGOS_MODULE_NAME: &str = \"{}\";\n\n",
|
||||
module.name
|
||||
));
|
||||
|
||||
// -- context ------------------------------------------------------------
|
||||
out.push_str(
|
||||
"#[derive(Debug, Clone, Default)]\n\
|
||||
@@ -1104,6 +1193,18 @@ __ABOUT_TO_UNLOAD_BODY__\n\
|
||||
/// point: set_context / set_emit_callback latch on full wiring;\n\
|
||||
/// dispatch passes require_emit = false as the no-event-host fallback.\n\
|
||||
fn ensure_ready(require_emit: bool) {\n\
|
||||
\x20 // FIRST, and before the author's install hook can construct\n\
|
||||
\x20 // anything: tell the SDK the name this image announces when it\n\
|
||||
\x20 // calls out. Every generated path that reaches author code runs\n\
|
||||
\x20 // through here -- install/T::default, on_context_ready, dispatch,\n\
|
||||
\x20 // and (transitively) about_to_unload, which answers 0 unless\n\
|
||||
\x20 // install already ran -- so the origin is set before the first\n\
|
||||
\x20 // outbound client exists. Without a name the SDK announces\n\
|
||||
\x20 // nothing and the capability handshake fails closed; with the\n\
|
||||
\x20 // wrong one (\"core\") it authorized as the host. The SDK also\n\
|
||||
\x20 // keys its client cache by origin, so even a client built before\n\
|
||||
\x20 // this ran cannot be reused after it. Idempotent: a OnceLock set.\n\
|
||||
\x20 logos_rust_sdk::set_module_origin(LOGOS_MODULE_NAME);\n\
|
||||
\x20 if REGISTERED.lock().unwrap().is_none() {\n\
|
||||
\x20 unsafe { __logos_install_hook::logos_module_install() };\n\
|
||||
\x20 }\n\
|
||||
@@ -1190,9 +1291,15 @@ __ABOUT_TO_UNLOAD_BODY__\n\
|
||||
\x20 if module_name.is_null() || token.is_null() {{ return -1; }}\n\
|
||||
\x20 let name = unsafe {{ CStr::from_ptr(module_name) }}.to_string_lossy().into_owned();\n\
|
||||
\x20 let tok = unsafe {{ CStr::from_ptr(token) }}.to_string_lossy().into_owned();\n\
|
||||
\x20 // The runtime handshake: hand the host-issued token to the SDK's\n\
|
||||
\x20 // THE OUTBOUND DOOR. Hand the host-issued token to the SDK's\n\
|
||||
\x20 // protocol stack so this module's *outbound* calls authenticate —\n\
|
||||
\x20 // the same stack the typed client wrappers invoke through.\n\
|
||||
\x20 //\n\
|
||||
\x20 // ONE MEANING ONLY, as of protocol 0.8: the module's OWN anchor,\n\
|
||||
\x20 // seeded by the Qt glue's onInit. A CALLER's token goes through\n\
|
||||
\x20 // logos_module_accept_inbound_token instead. Do not merge them —\n\
|
||||
\x20 // one value written through the wrong door made every capability\n\
|
||||
\x20 // grant silently bidirectional.\n\
|
||||
\x20 logos_rust_sdk::save_token(&name, &tok);\n\
|
||||
\x20 TOKENS.lock().unwrap().push((name, tok));\n\
|
||||
\x20 0\n\
|
||||
@@ -1216,7 +1323,11 @@ __ABOUT_TO_UNLOAD_BODY__\n\
|
||||
"{}{}{}",
|
||||
grant_host_services_block(protocol_version),
|
||||
teardown_block(protocol_version),
|
||||
set_call_caller_block(protocol_version)
|
||||
format!(
|
||||
"{}{}",
|
||||
set_call_caller_block(protocol_version),
|
||||
accept_inbound_token_block(protocol_version)
|
||||
)
|
||||
)
|
||||
));
|
||||
|
||||
@@ -1371,6 +1482,107 @@ module rust_calc {
|
||||
assert!(next_major.contains("pub extern \"C\" fn logos_module_about_to_unload"));
|
||||
}
|
||||
|
||||
// ── protocol 0.8: the INBOUND door ──────────────────────────────────
|
||||
//
|
||||
// The fourth time this rule is asked one bump early. The Qt glue's
|
||||
// informModuleToken emits a DIRECT call to this export, so a scaffold that
|
||||
// does not define it links clean and dies at dlopen() on ELF, invisibly on
|
||||
// macOS.
|
||||
#[test]
|
||||
fn protocol_0_8_emits_the_inbound_token_export() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
let code = generate_provider(&m, "0.8.0");
|
||||
assert!(
|
||||
code.contains("pub extern \"C\" fn logos_module_accept_inbound_token"),
|
||||
"{code}"
|
||||
);
|
||||
// It forwards to the INBOUND lp_* entry point, not the outbound one.
|
||||
// This is the assertion that matters: both spellings compile, link,
|
||||
// load and return 0, and the wrong one silently makes every capability
|
||||
// grant bidirectional.
|
||||
assert!(code.contains("unsafe { lp_token_save_inbound(caller, token) }"), "{code}");
|
||||
// And the binding is declared INSIDE the gated block, so no
|
||||
// unconditional item in the SDK crate references a symbol a pre-0.8
|
||||
// logos-protocol does not export. Without this, every Rust module --
|
||||
// at every protocol version -- failed at dlopen on Linux with
|
||||
// "undefined symbol: lp_token_save_inbound".
|
||||
assert!(
|
||||
code.contains("fn lp_token_save_inbound(caller: *const c_char, token: *const c_char) -> c_int;"),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
|
||||
// The OTHER half of the same claim, and the one a refactor would break:
|
||||
// the outbound door must still exist and must still be outbound. onInit
|
||||
// seeds the module's OWN anchor through it, and a module that lost that
|
||||
// write could not authenticate a single call of its own.
|
||||
#[test]
|
||||
fn the_outbound_door_stays_outbound_at_0_8() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
let code = generate_provider(&m, "0.8.0");
|
||||
let body = code
|
||||
.split("pub extern \"C\" fn logos_module_accept_token")
|
||||
.nth(1)
|
||||
.expect("the outbound export is emitted at every protocol version")
|
||||
.split("\n}")
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
assert!(body.contains("logos_rust_sdk::save_token(&name, &tok)"), "{body}");
|
||||
assert!(!body.contains("lp_token_save_inbound"), "{body}");
|
||||
|
||||
let inbound = code
|
||||
.split("pub extern \"C\" fn logos_module_accept_inbound_token")
|
||||
.nth(1)
|
||||
.expect("the inbound export is emitted at 0.8")
|
||||
.split("\n}")
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
assert!(!inbound.contains("save_token(&name"), "{inbound}");
|
||||
}
|
||||
|
||||
// Older protocols do not declare it, so emitting it there would only move
|
||||
// the undefined symbol to the other side of the seam — and would not
|
||||
// compile anyway, since lp_token_save_inbound does not exist below 0.8.
|
||||
#[test]
|
||||
fn protocol_0_7_emits_no_inbound_token_export() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
let code = generate_provider(&m, "0.7.0");
|
||||
// Anchored on the DEFINITION form, not the bare name: the outbound
|
||||
// export's own comment names the inbound one (that cross-reference is
|
||||
// the point of it), and a looser anchor would count a mention as a
|
||||
// definition — the exact shape of the bug this family exists to catch.
|
||||
assert!(
|
||||
!code.contains("pub extern \"C\" fn logos_module_accept_inbound_token"),
|
||||
"{code}"
|
||||
);
|
||||
// And, the half that actually broke a build: no reference to the 0.8
|
||||
// lp_* symbol survives either. A pre-0.8 logos-protocol does not export
|
||||
// it, and an ELF module links clean and dies at dlopen.
|
||||
assert!(!code.contains("lp_token_save_inbound"), "{code}");
|
||||
// ...while every earlier wave is untouched. The gates are PER BLOCK,
|
||||
// which is what makes landing this definition before the protocol bump
|
||||
// reaches this repo's lock inert: at the current pin the scaffold is
|
||||
// byte-identical to master's.
|
||||
assert!(code.contains("pub extern \"C\" fn logos_module_set_call_caller"), "{code}");
|
||||
assert!(code.contains("pub extern \"C\" fn logos_module_about_to_unload"), "{code}");
|
||||
assert!(code.contains("pub extern \"C\" fn logos_module_accept_token"), "{code}");
|
||||
}
|
||||
|
||||
// MAJOR-aware, not MINOR-alone. The C++ emitter spells this as preprocessor
|
||||
// arithmetic, where `MINOR >= 8` silently drops the export at 1.0 — taking
|
||||
// the glue's call with it, so nothing fails to build, nothing fails to
|
||||
// load, and every module quietly goes back to filing its callers as
|
||||
// outbound credentials.
|
||||
#[test]
|
||||
fn the_0_8_gate_is_major_aware_not_minor_alone() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
let next_major = generate_provider(&m, "1.0.0");
|
||||
assert!(
|
||||
next_major.contains("pub extern \"C\" fn logos_module_accept_inbound_token"),
|
||||
"{next_major}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The module-impl exports a scaffold DEFINES, by name. Anchored on the
|
||||
/// definition form and nothing looser: the scaffold also emits an
|
||||
/// `extern "Rust"` install hook (not a member of this ABI), and a looser
|
||||
@@ -1866,4 +2078,78 @@ module v_module {
|
||||
assert!(code.contains(r#""1.0.0".to_string()"#), "{code}");
|
||||
}
|
||||
|
||||
|
||||
// ── the module's own name is what it announces when it calls out ──────────
|
||||
//
|
||||
// `lp_client_create(target, origin, ..)`'s ORIGIN is the caller's
|
||||
// self-declared identity: capability_module checks it against its
|
||||
// known-caller roster and the target's access policy, the target files the
|
||||
// minted token under it, and a callee's `current_caller()` reports it. The
|
||||
// Rust SDK announced the literal "core" — a bootstrapKeys() ANCHOR label,
|
||||
// not a module name — for every Rust module ever built. MEASURED on a real
|
||||
// fleet before this: `requestModule for origin: "core"` and
|
||||
// `ModuleProxy: Token saved for module: "core"` at the victim.
|
||||
//
|
||||
// The name is not something the module-impl C ABI can hand a module at
|
||||
// runtime (there is no self-name export, and set_context's instance_id is
|
||||
// a per-INSTANCE id). It is a fact about the contract, so it is baked here
|
||||
// — the same way the C++ generated umbrella bakes metadata.json#name into
|
||||
// `logos::LpClient(target, origin)`.
|
||||
#[test]
|
||||
fn the_scaffold_declares_the_contracts_own_module_name() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
let code = generate_provider(&m, "0.8.0");
|
||||
assert!(
|
||||
code.contains(r#"pub const LOGOS_MODULE_NAME: &str = "rust_calc";"#),
|
||||
"the scaffold must carry the contract's own module name: {code}"
|
||||
);
|
||||
// Not any of the anchor labels. A module named after one would be
|
||||
// indistinguishable from the host at every callee, which is the whole
|
||||
// failure this closes; the contract's name is the only right answer.
|
||||
assert!(
|
||||
!code.contains(r#"LOGOS_MODULE_NAME: &str = "core""#),
|
||||
"the module name must come from the contract, never from a literal"
|
||||
);
|
||||
}
|
||||
|
||||
/// The latch must be reachable BEFORE any code of the author's can run,
|
||||
/// because that is the only code that can build an outbound client. Every
|
||||
/// C-ABI entry point that leads to author code goes through `ensure_ready`,
|
||||
/// so the latch belongs at its head — ahead of the install hook, which is
|
||||
/// what constructs the author's impl (and whose `Default` may itself call
|
||||
/// out).
|
||||
///
|
||||
/// Asserted as an ORDER inside `ensure_ready`, not merely as presence:
|
||||
/// `set_module_origin` after `logos_module_install()` would still appear in
|
||||
/// the file, still compile, and still be wrong for the first call a module
|
||||
/// makes from its own constructor.
|
||||
#[test]
|
||||
fn ensure_ready_latches_the_origin_before_the_author_install_hook() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
for (label, emit_trait, multi) in [
|
||||
("default-trait,single", true, false),
|
||||
("default-trait,multi", true, true),
|
||||
("no-trait,single", false, false),
|
||||
("no-trait,multi", false, true),
|
||||
] {
|
||||
let code = generate_provider_with(&m, "0.8.0", emit_trait, multi);
|
||||
let head = code
|
||||
.find("fn ensure_ready(require_emit: bool) {")
|
||||
.unwrap_or_else(|| panic!("[{label}] no ensure_ready in the scaffold"));
|
||||
let body = &code[head..];
|
||||
let end = body.find("\nmod __logos_install_hook").unwrap_or(body.len());
|
||||
let body = &body[..end];
|
||||
let latch = body
|
||||
.find("logos_rust_sdk::set_module_origin(LOGOS_MODULE_NAME);")
|
||||
.unwrap_or_else(|| panic!("[{label}] ensure_ready does not latch the origin: {body}"));
|
||||
let install = body
|
||||
.find("__logos_install_hook::logos_module_install()")
|
||||
.unwrap_or_else(|| panic!("[{label}] ensure_ready does not call the install hook"));
|
||||
assert!(
|
||||
latch < install,
|
||||
"[{label}] the origin is latched AFTER the author's install hook, so an \
|
||||
outbound call made from the impl's constructor would announce nothing: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -104,6 +104,76 @@ impl Default for LogosModuleSDK {
|
||||
}
|
||||
}
|
||||
|
||||
// -- module origin ----------------------------------------------------------
|
||||
//
|
||||
// THE NAME THIS MODULE ANNOUNCES WHEN IT CALLS OUT.
|
||||
//
|
||||
// `lp_client_create(target, origin, ..)` takes an origin, and that string is
|
||||
// this module's SELF-DECLARED IDENTITY on the capability handshake: the
|
||||
// protocol forwards it to `capability_module.requestModule(origin, target)`,
|
||||
// which checks it against its known-caller roster, checks it against the
|
||||
// target's access policy, and then PUSHES the minted token to the target
|
||||
// naming `origin` as the caller. The target files it under that name, and it
|
||||
// is that name a later `currentCaller()` reports.
|
||||
//
|
||||
// This crate used to pass the literal `"core"` there, for every Rust module in
|
||||
// the fleet. `"core"` is not a module name: it is one of
|
||||
// `TokenManager::bootstrapKeys()`, the role labels a host-issued ANCHOR is
|
||||
// installed under. Measured consequences of announcing it, end to end:
|
||||
//
|
||||
// * below protocol 0.8 the target's push landed in its OUTBOUND namespace,
|
||||
// which is exactly where `credentialLocked()` reads — so an ordinary
|
||||
// capability-minted pair token BECAME the target's own credential, and the
|
||||
// target's real anchor was destroyed (the host itself locked out of it);
|
||||
// * `currentCaller()` at the target could not name the caller: the key it
|
||||
// scans is the anchor label, which ModuleProxy read as the HOST anchor
|
||||
// (`caller_kind=host`) until that read was tightened to Unknown. Neither
|
||||
// answer is the module, so an identity check at the callee was decided by
|
||||
// a name the caller made up;
|
||||
// * every derived access policy carries `"core"` in its allowedCallers
|
||||
// (liblogos' kTrustedCallers), so the policy was silently widened to
|
||||
// permit any Rust module to call any target.
|
||||
//
|
||||
// The C++ SDK never had this: `logos::LpClient` takes the origin as a
|
||||
// constructor parameter and the generated umbrella bakes `metadata.json#name`
|
||||
// into it. This is the Rust equivalent of that bake. The name is known at
|
||||
// GENERATION time — it is the LIDL contract's `module.name`, the same string
|
||||
// the C++ umbrella uses — so the generated provider scaffold emits it as a
|
||||
// `const` and latches it here through `set_module_origin` before any code of
|
||||
// the author's can run.
|
||||
//
|
||||
// A process-global rather than a parameter, because the origin is a property
|
||||
// of the IMAGE, not of a call or a target: one cdylib is one module, and the
|
||||
// hand-written `LogosModuleSDK::new().plugin(dep)` form (which real modules
|
||||
// use beside `modules().<dep>`) has nowhere to put a parameter.
|
||||
static MODULE_ORIGIN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
|
||||
/// Declare the name this module announces as the origin of its outbound calls.
|
||||
///
|
||||
/// Called by the generated provider scaffold with the contract's own module
|
||||
/// name, from every C-ABI entry point that can precede author code — so by the
|
||||
/// time a module builds its first client the origin is already set. A module
|
||||
/// authored without the scaffold (a Rust binary calling in from outside a
|
||||
/// plugin, over `lib.callerBuildSupport`) must call this itself.
|
||||
///
|
||||
/// SET ONCE. Returns `true` if this call established the origin or repeated the
|
||||
/// value already set; `false` if a DIFFERENT name was already latched, which is
|
||||
/// refused — a second identity in one image would silently re-key every client
|
||||
/// created after it. Repeating the same name is the normal case: the scaffold
|
||||
/// calls this on every entry point.
|
||||
pub fn set_module_origin(name: &str) -> bool {
|
||||
match MODULE_ORIGIN.set(name.to_string()) {
|
||||
Ok(()) => true,
|
||||
Err(_) => MODULE_ORIGIN.get().map(String::as_str) == Some(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name this module announces as the origin of its outbound calls, or
|
||||
/// `None` when nothing has declared one.
|
||||
pub fn module_origin() -> Option<&'static str> {
|
||||
MODULE_ORIGIN.get().map(String::as_str)
|
||||
}
|
||||
|
||||
// -- teardown ---------------------------------------------------------------
|
||||
//
|
||||
// The module-impl C ABI gained two teardown exports in logos-protocol 0.5
|
||||
|
||||
+14
@@ -77,6 +77,20 @@ extern "C" {
|
||||
|
||||
pub fn lp_token_save(module_name: *const c_char, token: *const c_char) -> c_int;
|
||||
|
||||
// THE INBOUND DOOR (logos-protocol 0.8, lp_token_save_inbound) is
|
||||
// DELIBERATELY NOT DECLARED HERE. It is declared inside the generated
|
||||
// provider scaffold, under the same 0.8 gate as the export that calls it --
|
||||
// see lidl-gen's accept_inbound_token_block.
|
||||
//
|
||||
// The reason is a link requirement, not taste. A wrapper in api.rs sits in
|
||||
// the same rlib object as save_token, which every module needs, so the
|
||||
// linker pulls the object in and the undefined lp_token_save_inbound
|
||||
// reference with it -- for EVERY module, including ones generated for
|
||||
// protocol 0.7 that emit no call at all. Measured: every Rust module died
|
||||
// at dlopen with "undefined symbol: lp_token_save_inbound", on Linux only.
|
||||
// Keeping the binding inside the gated block is what makes landing this
|
||||
// ahead of the protocol bump inert.
|
||||
|
||||
pub fn lp_subscribe(
|
||||
client: *mut LpClient,
|
||||
event_name: *const c_char,
|
||||
|
||||
+36
-3
@@ -47,6 +47,39 @@ pub use error::LogosError;
|
||||
pub use params::{Param, ToParam};
|
||||
pub use callback::{CallResult, EventData};
|
||||
pub use plugin::{EventSubscription, PluginProxy};
|
||||
pub use api::{current_caller, current_caller_json, grant_host_services, protocol_abi_major,
|
||||
protocol_version, save_token, set_call_caller, set_unload_done_callback,
|
||||
unload_finished, LogosCaller, LogosModuleSDK, Shutdown, UnloadDoneCb};
|
||||
pub use api::{current_caller, current_caller_json, grant_host_services, module_origin,
|
||||
protocol_abi_major, protocol_version, save_token, set_call_caller,
|
||||
set_module_origin, set_unload_done_callback, unload_finished, LogosCaller,
|
||||
LogosModuleSDK, Shutdown, UnloadDoneCb};
|
||||
|
||||
// EVERY PATH THE GENERATED PROVIDER SCAFFOLD SPELLS, RESOLVED AT COMPILE TIME.
|
||||
//
|
||||
// lidl-gen emits calls like `logos_rust_sdk::save_token(&name, &tok)`
|
||||
// as TEXT, and lidl-gen's own tests only grep that text. Nothing else in this
|
||||
// repo compiles a scaffold against this crate at a protocol version where the
|
||||
// newest call is emitted: `module-impl-abi` reads the emitted source and never
|
||||
// builds it, and `ipc-test` builds modules at whatever protocol
|
||||
// logos-module-builder pins, which lags the declaration by design.
|
||||
//
|
||||
// So a function that exists in api.rs but is missing from the `pub use` above
|
||||
// passes every check in this repo and fails three repos downstream, in a module
|
||||
// build, with "cannot find function ... in crate `logos_rust_sdk`" and a
|
||||
// dead-code warning as the only hint. That is not hypothetical: it is what the
|
||||
// first cut of the 0.8 inbound door did, and the failure surfaced only when a
|
||||
// real module was compiled against a real 0.8 protocol.
|
||||
//
|
||||
// A `use` resolves the path and generates no code, so this costs nothing and
|
||||
// cannot introduce an undefined lp_* symbol into a test binary. Add a line here
|
||||
// whenever the generator learns to call something new BY PATH.
|
||||
//
|
||||
// Note what is deliberately absent: the 0.8 inbound door. lidl-gen declares and
|
||||
// calls lp_token_save_inbound inside its own gated block precisely so that no
|
||||
// unconditional item in this crate references a symbol older protocols do not
|
||||
// export -- see the note in ffi.rs.
|
||||
#[allow(unused_imports)]
|
||||
mod generated_scaffold_paths {
|
||||
use crate::{
|
||||
grant_host_services, save_token, set_call_caller, set_module_origin,
|
||||
set_unload_done_callback, unload_finished,
|
||||
};
|
||||
}
|
||||
|
||||
+123
-12
@@ -98,7 +98,7 @@ impl Drop for ClientHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-global cache of ONE shared `lp_client` per target module name.
|
||||
/// Process-global cache of ONE shared `lp_client` per (origin, target) pair.
|
||||
///
|
||||
/// Why: the protocol coalesces concurrent capability handshakes PER client, so
|
||||
/// a fan-out that opens a fresh client per call (the old `modules().dep.x()`
|
||||
@@ -112,18 +112,66 @@ impl Drop for ClientHandle {
|
||||
/// when the last drops, `lp_client_destroy` fires (teardown unchanged) and a
|
||||
/// later lookup re-creates. Sharing across threads is sound by the same
|
||||
/// per-handle thread-safety contract `EventSubscription` already relies on.
|
||||
fn client_cache() -> &'static Mutex<HashMap<String, Weak<ClientHandle>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Weak<ClientHandle>>>> = OnceLock::new();
|
||||
///
|
||||
/// Keyed by (origin, target), not by target alone. The origin is latched once
|
||||
/// per image (`set_module_origin`) and in a module it is latched before any
|
||||
/// author code runs, so in practice one origin ever appears — but a client
|
||||
/// carries its origin for life, and it is the origin the capability handshake
|
||||
/// authenticates. Keying on it means a client built before the latch can never
|
||||
/// be handed back after it, under a name it does not actually announce.
|
||||
fn client_cache() -> &'static Mutex<HashMap<(String, String), Weak<ClientHandle>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, String), Weak<ClientHandle>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Get-or-create the shared client for `target` (origin is always "core" here).
|
||||
/// The origin every client this image creates announces — this module's own
|
||||
/// name, latched by the generated scaffold (see `api::set_module_origin`).
|
||||
///
|
||||
/// Empty when nothing declared one. Empty is the FAIL-CLOSED answer, and it is
|
||||
/// deliberately not a guess: `capability_module::requestModule` refuses an
|
||||
/// empty `fromModuleName` outright ("rejecting empty module name"), and
|
||||
/// `ModuleProxy::saveToken` refuses to file a token under an empty caller. The
|
||||
/// alternative — inventing a plausible name — is precisely the bug this
|
||||
/// replaces, because the plausible name that was invented ("core") happened to
|
||||
/// be a bootstrapKeys() anchor, and so carried the host's authority wherever
|
||||
/// the callee looked at it.
|
||||
///
|
||||
/// Warned once per process rather than per client: the cause is one missing
|
||||
/// declaration in the image, not a property of the call that tripped over it.
|
||||
fn outbound_origin() -> String {
|
||||
match crate::api::module_origin() {
|
||||
Some(name) => name.to_string(),
|
||||
None => {
|
||||
static WARNED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
eprintln!(
|
||||
"logos-rust-sdk: creating an outbound client with NO module origin \
|
||||
declared — the capability handshake will be refused. A module built \
|
||||
by logos-module-builder gets this from its generated scaffold; a \
|
||||
caller outside a plugin must call \
|
||||
logos_rust_sdk::set_module_origin(\"<its own module name>\") first."
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get-or-create the shared client for `target`, announcing THIS module's own
|
||||
/// name as the origin.
|
||||
/// Returns None on the same failure surface as before (bad name / null client).
|
||||
fn shared_client(target: &str) -> Option<Arc<ClientHandle>> {
|
||||
// Fast path: a live client for this target already exists.
|
||||
// Read the origin ONCE, before the lookup, and use that same value for the
|
||||
// key and for lp_client_create. Reading it twice could straddle the latch
|
||||
// and publish a client under a key it was not built with.
|
||||
let origin = outbound_origin();
|
||||
let key = (origin.clone(), target.to_string());
|
||||
|
||||
// Fast path: a live client for this (origin, target) already exists.
|
||||
if let Some(existing) = {
|
||||
let map = client_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.get(target).and_then(Weak::upgrade)
|
||||
map.get(&key).and_then(Weak::upgrade)
|
||||
} {
|
||||
return Some(existing);
|
||||
}
|
||||
@@ -140,9 +188,11 @@ fn shared_client(target: &str) -> Option<Arc<ClientHandle>> {
|
||||
// and logos-qt-sdk's LpBridge::resultClient avoid the identical hazard the
|
||||
// same way.)
|
||||
let target_c = CString::new(target).ok()?;
|
||||
let origin = CString::new("core").unwrap();
|
||||
// The module's OWN name. Not a literal: see api::set_module_origin for what
|
||||
// announcing a bootstrapKeys() label instead was measured to do.
|
||||
let origin_c = CString::new(origin.as_str()).ok()?;
|
||||
let raw = unsafe {
|
||||
ffi::lp_client_create(target_c.as_ptr(), origin.as_ptr(), ptr::null(), ptr::null())
|
||||
ffi::lp_client_create(target_c.as_ptr(), origin_c.as_ptr(), ptr::null(), ptr::null())
|
||||
};
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
@@ -155,10 +205,10 @@ fn shared_client(target: &str) -> Option<Arc<ClientHandle>> {
|
||||
// owner thread), so the "one shared client per target" invariant that makes
|
||||
// concurrent calls coalesce into a single capability handshake still holds.
|
||||
let mut map = client_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(existing) = map.get(target).and_then(Weak::upgrade) {
|
||||
if let Some(existing) = map.get(&key).and_then(Weak::upgrade) {
|
||||
return Some(existing);
|
||||
}
|
||||
map.insert(target.to_string(), Arc::downgrade(&handle));
|
||||
map.insert(key, Arc::downgrade(&handle));
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
@@ -291,8 +341,8 @@ pub struct PluginProxy {
|
||||
impl PluginProxy {
|
||||
pub(crate) fn new(plugin_name: impl Into<String>) -> Self {
|
||||
let plugin_name = plugin_name.into();
|
||||
// Share ONE lp_client per target (origin "core") across all proxies for
|
||||
// that target, so a concurrent fan-out coalesces to a single capability
|
||||
// Share ONE lp_client per (origin, target) across all proxies for that
|
||||
// target, so a concurrent fan-out coalesces to a single capability
|
||||
// handshake instead of racing N. See client_cache()/shared_client().
|
||||
let client = shared_client(&plugin_name);
|
||||
PluginProxy { plugin_name, client }
|
||||
@@ -843,3 +893,64 @@ mod timeout_tests {
|
||||
assert!(too_big.contains("clamped"), "{}", too_big);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use super::*;
|
||||
use crate::api::{module_origin, set_module_origin};
|
||||
|
||||
/// ONE test, deliberately, for a process-global `OnceLock`: the states are
|
||||
/// ordered (unset → set → contested) and `cargo test` runs test functions
|
||||
/// concurrently in a single process, so splitting them would make the
|
||||
/// "unset" assertion race whichever test set it first. Nothing else in the
|
||||
/// crate touches the origin, so the sequence below is the whole lifecycle.
|
||||
///
|
||||
/// What this pins, and why each half matters:
|
||||
///
|
||||
/// * UNSET reads as EMPTY, not as a guess. `shared_client` hands this
|
||||
/// string to `lp_client_create` as the origin, and the origin is the
|
||||
/// identity `capability_module.requestModule` authenticates. An empty
|
||||
/// one is refused there by name ("rejecting empty module name") — fail
|
||||
/// closed. The alternative, a plausible-looking default, is the very
|
||||
/// defect this replaces: the default that was there was "core", a
|
||||
/// `TokenManager::bootstrapKeys()` anchor, so every Rust module in the
|
||||
/// fleet announced itself as the HOST.
|
||||
///
|
||||
/// * A REPEAT of the same name succeeds. The generated scaffold calls
|
||||
/// `set_module_origin` from `ensure_ready`, i.e. on every C-ABI entry
|
||||
/// point, so "already set to this" is the common case and must not read
|
||||
/// as a failure.
|
||||
///
|
||||
/// * A DIFFERENT name is refused AND does not take effect. One cdylib is
|
||||
/// one module; a second identity latching over the first would re-key
|
||||
/// every client created after it, and silently.
|
||||
#[test]
|
||||
fn the_origin_is_declared_once_and_never_guessed() {
|
||||
assert_eq!(module_origin(), None, "nothing may pre-set the origin");
|
||||
assert_eq!(
|
||||
outbound_origin(),
|
||||
"",
|
||||
"an undeclared origin must be empty (fail closed), never a default name"
|
||||
);
|
||||
|
||||
assert!(set_module_origin("calc_aggregator"), "first declaration");
|
||||
assert_eq!(module_origin(), Some("calc_aggregator"));
|
||||
assert_eq!(outbound_origin(), "calc_aggregator");
|
||||
|
||||
assert!(
|
||||
set_module_origin("calc_aggregator"),
|
||||
"re-declaring the SAME name is what the scaffold does on every entry point"
|
||||
);
|
||||
assert_eq!(outbound_origin(), "calc_aggregator");
|
||||
|
||||
assert!(
|
||||
!set_module_origin("core"),
|
||||
"a second, different identity must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
outbound_origin(),
|
||||
"calc_aggregator",
|
||||
"a refused re-declaration must not take effect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// This module's own name, from the LIDL contract.
|
||||
///
|
||||
/// The ORIGIN of every outbound call this image makes: it is what
|
||||
/// capability_module checks against its known-caller roster and the
|
||||
/// target's access policy, what the target files the minted token
|
||||
/// under, and what a callee's `current_caller()` reports.
|
||||
pub const LOGOS_MODULE_NAME: &str = "sdk_test_caller_module";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RustModuleContext {
|
||||
pub module_path: String,
|
||||
@@ -232,6 +240,18 @@ pub fn install<T: SdkTestCallerModule + Default>() {
|
||||
/// point: set_context / set_emit_callback latch on full wiring;
|
||||
/// dispatch passes require_emit = false as the no-event-host fallback.
|
||||
fn ensure_ready(require_emit: bool) {
|
||||
// FIRST, and before the author's install hook can construct
|
||||
// anything: tell the SDK the name this image announces when it
|
||||
// calls out. Every generated path that reaches author code runs
|
||||
// through here -- install/T::default, on_context_ready, dispatch,
|
||||
// and (transitively) about_to_unload, which answers 0 unless
|
||||
// install already ran -- so the origin is set before the first
|
||||
// outbound client exists. Without a name the SDK announces
|
||||
// nothing and the capability handshake fails closed; with the
|
||||
// wrong one ("core") it authorized as the host. The SDK also
|
||||
// keys its client cache by origin, so even a client built before
|
||||
// this ran cannot be reused after it. Idempotent: a OnceLock set.
|
||||
logos_rust_sdk::set_module_origin(LOGOS_MODULE_NAME);
|
||||
if REGISTERED.lock().unwrap().is_none() {
|
||||
unsafe { __logos_install_hook::logos_module_install() };
|
||||
}
|
||||
@@ -321,9 +341,15 @@ pub extern "C" fn logos_module_accept_token(module_name: *const c_char, token: *
|
||||
if module_name.is_null() || token.is_null() { return -1; }
|
||||
let name = unsafe { CStr::from_ptr(module_name) }.to_string_lossy().into_owned();
|
||||
let tok = unsafe { CStr::from_ptr(token) }.to_string_lossy().into_owned();
|
||||
// The runtime handshake: hand the host-issued token to the SDK's
|
||||
// THE OUTBOUND DOOR. Hand the host-issued token to the SDK's
|
||||
// protocol stack so this module's *outbound* calls authenticate —
|
||||
// the same stack the typed client wrappers invoke through.
|
||||
//
|
||||
// ONE MEANING ONLY, as of protocol 0.8: the module's OWN anchor,
|
||||
// seeded by the Qt glue's onInit. A CALLER's token goes through
|
||||
// logos_module_accept_inbound_token instead. Do not merge them —
|
||||
// one value written through the wrong door made every capability
|
||||
// grant silently bidirectional.
|
||||
logos_rust_sdk::save_token(&name, &tok);
|
||||
TOKENS.lock().unwrap().push((name, tok));
|
||||
0
|
||||
|
||||
+74
-1
@@ -15,6 +15,11 @@ set -uo pipefail
|
||||
: "${out:?out must be set}"
|
||||
mkdir -p "$out"
|
||||
export QT_QPA_PLATFORM=offscreen
|
||||
# The capability handshake logs at qDebug, and section 4 below reads it. Turning
|
||||
# it on also makes the log this script cats on ANY failure worth reading — the
|
||||
# default one carries no requestModule/token traffic at all.
|
||||
export QT_LOGGING_RULES="*.debug=true;qt.remoteobjects*=false"
|
||||
export QT_FORCE_STDERR_LOGGING=1
|
||||
|
||||
# Inline (`-c`) mode is legacy; drive a logoscore daemon and call via the
|
||||
# `call` client subcommand. A persistent daemon keeps the Qt event loop running
|
||||
@@ -34,7 +39,9 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
logoscore -D --config-dir "$LOGOSCORE_CONFIG_DIR" -m "$MODULES_DIR" \
|
||||
# -v: without it the daemon drops the module subprocesses' own output, which is
|
||||
# where the capability handshake (section 4) is logged.
|
||||
logoscore -D -v --config-dir "$LOGOSCORE_CONFIG_DIR" -m "$MODULES_DIR" \
|
||||
>"$LOGOSCORE_CONFIG_DIR/daemon.log" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
# `status` is the definitive readiness probe; no need to poke at the daemon's
|
||||
@@ -281,6 +288,71 @@ printf '%s' "$after" | grep -qE '"result"[[:space:]]*:[[:space:]]*8[[:space:]]*[
|
||||
|| fail "call_add(5,3) broke after bounded calls — a timeout leaked: $after"
|
||||
echo " OK default-path call still works after bounded calls"
|
||||
|
||||
# ──────────────────────────────────── 4. the caller announces ITS OWN NAME
|
||||
#
|
||||
# `lp_client_create(target, origin, ..)`'s ORIGIN is the caller's self-declared
|
||||
# identity, and it is load-bearing three times over: capability_module checks it
|
||||
# against its known-caller roster AND against the target's access policy, and
|
||||
# then pushes the minted token to the target NAMING IT — which is the key the
|
||||
# target files the token under, and the name a later `current_caller()` reports.
|
||||
#
|
||||
# The Rust SDK announced the literal "core" here, for every Rust module ever
|
||||
# built. "core" is not a module name: it is one of TokenManager's
|
||||
# bootstrapKeys(), the role labels a host-issued ANCHOR lives under. Measured
|
||||
# consequences, all three real — below protocol 0.8 the push landed in the
|
||||
# target's OUTBOUND namespace, which is where credentialLocked() reads, so an
|
||||
# ordinary pair token BECAME the target's own credential and its real anchor was
|
||||
# destroyed; `current_caller()` at the target reported the HOST; and every
|
||||
# derived access policy lists "core" among its allowed callers (liblogos'
|
||||
# kTrustedCallers), so the policy was silently satisfied by any Rust module.
|
||||
#
|
||||
# Read off the wire, not from a getter: what the peer was TOLD is the only thing
|
||||
# that decides any of the above. Each assertion below is written by a DIFFERENT
|
||||
# process — the caller module, then capability_module — so no single image can
|
||||
# make them all true by itself.
|
||||
echo
|
||||
echo "--- the caller's declared origin ---"
|
||||
log="$LOGOSCORE_CONFIG_DIR/daemon.log"
|
||||
|
||||
# (a) what the caller ANNOUNCED (written by the caller module's process).
|
||||
grep -aq 'requestModule for origin: "sdk_test_caller_module"' "$log" \
|
||||
|| fail "the caller did not announce its own module name on the capability handshake"
|
||||
echo ' OK the caller announced origin "sdk_test_caller_module"'
|
||||
|
||||
# (b) what capability_module RECEIVED and admitted (its process). This is also
|
||||
# the answer to "does the roster know the real name?" — requestModule fails
|
||||
# closed on an identity it has no token for, so reaching the mint at all
|
||||
# means liblogos registered the module under its own name at load.
|
||||
grep -aq 'requestModule called with fromModuleName: "sdk_test_caller_module"' "$log" \
|
||||
|| fail "capability_module never saw a handshake from sdk_test_caller_module"
|
||||
grep -aq "rejecting request from unknown module identity 'sdk_test_caller_module'" "$log" \
|
||||
&& fail "capability_module refused the caller's real name — the known-caller roster does not carry it"
|
||||
echo ' OK capability_module admitted "sdk_test_caller_module" (known-caller roster carries it)'
|
||||
|
||||
# (c) the key the TARGET was told to file the token under (capability_module's
|
||||
# process, describing the push it made into the provider).
|
||||
grep -aq 'Successfully informed "sdk_test_provider_module" about token for "sdk_test_caller_module"' "$log" \
|
||||
|| fail "the minted token was not pushed to the provider under the caller's own name"
|
||||
echo ' OK the provider was told to file the token under "sdk_test_caller_module"'
|
||||
|
||||
# (d) the negative half, and the one that would have caught this: no module in
|
||||
# this fleet may present itself under a bootstrapKeys() anchor label. Those
|
||||
# names belong to the host, and a module wearing one authorizes as the host.
|
||||
for anchor in core capability_module; do
|
||||
grep -aq "requestModule for origin: \"$anchor\"" "$log" \
|
||||
&& fail "a module announced the bootstrap anchor \"$anchor\" as its own identity — it authorizes as the host at every callee"
|
||||
grep -aq "requestModule called with fromModuleName: \"$anchor\"" "$log" \
|
||||
&& fail "capability_module was handed the bootstrap anchor \"$anchor\" as a caller identity"
|
||||
done
|
||||
echo ' OK no module announced a bootstrapKeys() anchor ("core" / "capability_module") as its identity'
|
||||
|
||||
# (e) anti-vacuity: (a)-(d) are only evidence if a handshake ran at all. A fleet
|
||||
# where nothing called anything satisfies every one of them.
|
||||
handshakes=$(grep -ac 'requestModule for origin:' "$log" || true)
|
||||
[ "${handshakes:-0}" -ge 1 ] \
|
||||
|| fail "no capability handshake appears in the log at all — the assertions above are vacuous"
|
||||
echo " OK $handshakes capability handshake(s) actually happened"
|
||||
|
||||
{
|
||||
echo "IPC test passed: sdk_test_provider_module.add(5,3) returned 8 via IPC"
|
||||
echo "Binary event passed: blobReady payload received as 4096 bytes, checksum 8354754"
|
||||
@@ -291,5 +363,6 @@ echo " OK default-path call still works after bounded calls"
|
||||
echo " async sleep 6000ms under 800ms -> ${async_bounded}ms, under 3000ms -> ${async_bounded2}ms"
|
||||
echo " default path (timeout_ms <= 0) still waits the protocol's 20s: ${default_elapsed}ms"
|
||||
echo " sub-millisecond timeout refused, not rounded into the default"
|
||||
echo "Origin passed: the caller announced \"sdk_test_caller_module\", not a bootstrap anchor"
|
||||
} > "$out/result.txt"
|
||||
cat "$out/result.txt"
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// This module's own name, from the LIDL contract.
|
||||
///
|
||||
/// The ORIGIN of every outbound call this image makes: it is what
|
||||
/// capability_module checks against its known-caller roster and the
|
||||
/// target's access policy, what the target files the minted token
|
||||
/// under, and what a callee's `current_caller()` reports.
|
||||
pub const LOGOS_MODULE_NAME: &str = "sdk_test_provider_module";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RustModuleContext {
|
||||
pub module_path: String,
|
||||
@@ -186,6 +194,18 @@ pub fn install<T: SdkTestProviderModule + Default>() {
|
||||
/// point: set_context / set_emit_callback latch on full wiring;
|
||||
/// dispatch passes require_emit = false as the no-event-host fallback.
|
||||
fn ensure_ready(require_emit: bool) {
|
||||
// FIRST, and before the author's install hook can construct
|
||||
// anything: tell the SDK the name this image announces when it
|
||||
// calls out. Every generated path that reaches author code runs
|
||||
// through here -- install/T::default, on_context_ready, dispatch,
|
||||
// and (transitively) about_to_unload, which answers 0 unless
|
||||
// install already ran -- so the origin is set before the first
|
||||
// outbound client exists. Without a name the SDK announces
|
||||
// nothing and the capability handshake fails closed; with the
|
||||
// wrong one ("core") it authorized as the host. The SDK also
|
||||
// keys its client cache by origin, so even a client built before
|
||||
// this ran cannot be reused after it. Idempotent: a OnceLock set.
|
||||
logos_rust_sdk::set_module_origin(LOGOS_MODULE_NAME);
|
||||
if REGISTERED.lock().unwrap().is_none() {
|
||||
unsafe { __logos_install_hook::logos_module_install() };
|
||||
}
|
||||
@@ -275,9 +295,15 @@ pub extern "C" fn logos_module_accept_token(module_name: *const c_char, token: *
|
||||
if module_name.is_null() || token.is_null() { return -1; }
|
||||
let name = unsafe { CStr::from_ptr(module_name) }.to_string_lossy().into_owned();
|
||||
let tok = unsafe { CStr::from_ptr(token) }.to_string_lossy().into_owned();
|
||||
// The runtime handshake: hand the host-issued token to the SDK's
|
||||
// THE OUTBOUND DOOR. Hand the host-issued token to the SDK's
|
||||
// protocol stack so this module's *outbound* calls authenticate —
|
||||
// the same stack the typed client wrappers invoke through.
|
||||
//
|
||||
// ONE MEANING ONLY, as of protocol 0.8: the module's OWN anchor,
|
||||
// seeded by the Qt glue's onInit. A CALLER's token goes through
|
||||
// logos_module_accept_inbound_token instead. Do not merge them —
|
||||
// one value written through the wrong door made every capability
|
||||
// grant silently bidirectional.
|
||||
logos_rust_sdk::save_token(&name, &tok);
|
||||
TOKENS.lock().unwrap().push((name, tok));
|
||||
0
|
||||
|
||||
Reference in New Issue
Block a user