feat(v1): harden typed runtime boundaries

Elect one canonical EffectBatch codec, remove the parallel postcard API, and strengthen fail-closed host, form, sync, WASM, macro, generated-contract, and test-harness proofs with mutation-driven coverage.

req: wire/008

req: wire/009

req: wire/010

req: push/008

req: client_local/015

req: client_local/016

req: client_local/017

req: client_local/018

req: client_local/019

req: sync/024

req: sync/025

req: sync/026

req: sync/027

req: sync/028

req: sync/029

req: test/020

req: test/021
This commit is contained in:
slhx agent
2026-07-16 22:27:28 +02:00
parent e7211df4c5
commit e9ced4e0c1
19 changed files with 1529 additions and 198 deletions
+260 -21
View File
@@ -1,8 +1,8 @@
use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey,
ScrollBehavior, Slot,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
};
#[test]
@@ -53,47 +53,70 @@ fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
}
#[test]
fn encoded_len_covers_every_effect_shape() {
let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1));
let scoped = ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 2),
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
let atom = ResourceRef::scoped(
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
ScopeKey::KeyValue(String::from("row")),
);
let handle = ResourceRef::scoped(
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
ScopeKey::Field(String::from("email")),
);
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(42),
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
ops: vec![
Effect::Put {
target: unscoped.clone(),
target: slot.clone(),
payload: Payload::Text(String::from("text")),
},
Effect::Put {
target: handle.clone(),
payload: Payload::Html(String::from("<p>safe</p>")),
},
Effect::Insert {
target: scoped.clone(),
target: atom.clone(),
key: String::from("insert"),
payload: Payload::Text(String::from("one")),
},
Effect::Prepend {
target: scoped.clone(),
target: form.clone(),
key: String::from("prepend"),
payload: Payload::Text(String::from("two")),
payload: Payload::Html(String::from("two")),
},
Effect::Remove {
target: scoped.clone(),
target: slot.clone(),
key: None,
},
Effect::Remove {
target: atom,
key: Some(String::from("remove")),
},
Effect::Move {
target: scoped.clone(),
target: handle.clone(),
key: String::from("move"),
before: Some(String::from("before")),
},
Effect::Focus {
target: scoped.clone(),
Effect::Focus { target: form },
Effect::Navigate {
url: String::from("/push"),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
},
Effect::Navigate {
url: String::from("/next"),
url: String::from("/replace"),
mode: NavigateMode::Replace,
scroll: ScrollBehavior::Element(unscoped),
title: Some(String::from("Next")),
scroll: ScrollBehavior::Top,
title: Some(String::from("Replace")),
},
Effect::Navigate {
url: String::from("/redirect"),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Element(handle),
title: Some(String::from("Redirect")),
},
Effect::Emit {
name: String::from("notice"),
@@ -103,8 +126,90 @@ fn encoded_len_covers_every_effect_shape() {
};
let bytes = batch.to_wire();
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch);
assert_eq!(&bytes[..4], b"HEMX");
assert_eq!(batch.encoded_len(), bytes.len());
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
for end in 0..bytes.len() {
assert_eq!(
EffectBatch::from_wire(&bytes[..end]),
Err(WireError::Truncated),
"prefix ending at byte {end} must fail closed"
);
}
// req: wire/007 test req: wire/008 test req: wire/009 test
}
#[test]
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4;
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN;
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1;
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4;
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1;
let put = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Put {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
payload: Payload::Text(String::from("value")),
}],
}
.to_wire();
for offset in [
PUT_EFFECT_TAG,
PUT_RESOURCE_KIND_TAG,
PUT_SCOPE_TAG,
PUT_PAYLOAD_TAG,
] {
let mut corrupt = put.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
const NAVIGATE_MODE_TAG: usize = BATCH_HEADER_LEN + 1 + 4;
const NAVIGATE_SCROLL_TAG: usize = NAVIGATE_MODE_TAG + 1;
const NAVIGATE_TITLE_OPTION_TAG: usize = NAVIGATE_SCROLL_TAG + 1;
let navigate = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Navigate {
url: String::new(),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
}],
}
.to_wire();
for offset in [
NAVIGATE_MODE_TAG,
NAVIGATE_SCROLL_TAG,
NAVIGATE_TITLE_OPTION_TAG,
] {
let mut corrupt = navigate.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
let mut bad_magic = put.clone();
bad_magic[0] = b'X';
assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4;
let mut invalid_utf8 = put.clone();
invalid_utf8[PUT_TEXT_START] = 0xff;
assert_eq!(
EffectBatch::from_wire(&invalid_utf8),
Err(WireError::InvalidUtf8)
);
let mut trailing = put;
trailing.push(0);
assert_eq!(
EffectBatch::from_wire(&trailing),
Err(WireError::TrailingBytes)
);
// req: wire/008 test req: wire/009 test
}
#[test]
@@ -196,6 +301,93 @@ fn generated_form_helpers_target_form_fields() {
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
}
#[test]
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
let rows = KeyedSlot::<u64, String>::new(9);
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9));
assert_eq!(
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")),
Effect::Put {
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())),
payload: Payload::Html("<li>done</li>".into()),
}
);
assert_eq!(
rows.remove(12),
Effect::Remove {
target: expected.clone(),
key: Some("12".into()),
}
);
assert_eq!(
rows.move_before(12, 13),
Effect::Move {
target: expected.clone(),
key: "12".into(),
before: Some("13".into()),
}
);
assert_eq!(
rows.move_to_end(12),
Effect::Move {
target: expected,
key: "12".into(),
before: None,
}
);
let form = Form::<()>::new(4);
assert_eq!(
form.clear_field("email"),
Effect::Put {
target: ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 4),
ScopeKey::Field("email".into()),
),
payload: Payload::Text(String::new()),
}
);
assert_eq!(
form.disable_while_pending(),
Effect::Emit {
name: "hemx:form-disable-while-pending".into(),
payload: "4".into(),
}
);
assert_eq!(form.clear(), form.reset());
for (effect, expected_mode) in [
(navigate("/push"), NavigateMode::Push),
(hemx_core::push("/push"), NavigateMode::Push),
(replace("/replace"), NavigateMode::Replace),
(redirect("/redirect"), NavigateMode::Redirect),
] {
let Effect::Navigate {
mode,
scroll,
title,
..
} = effect
else {
panic!("navigation helper must return Navigate");
};
assert_eq!(mode, expected_mode);
assert_eq!(scroll, ScrollBehavior::Top);
assert_eq!(title, None);
}
// test req: list/003 req: form_effects/001 req: nav/001
}
#[test]
fn css_class_accumulation_preserves_existing_classes() {
const A: CssClass = CssClass::new("a");
const B: CssClass = CssClass::new("b");
const C: CssClass = CssClass::new("c");
assert_eq!(CssClasses::new([]).with(A).as_str(), "a");
assert_eq!(CssClasses::from(A).with(B).with(C).as_str(), "a b c");
// test req: style/003
}
#[test]
fn slot_html_requires_explicit_safe_html() {
let content = Slot::<String>::new(10);
@@ -216,6 +408,8 @@ fn safe_html_joins_only_explicit_safe_fragments() {
]);
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>");
assert_eq!(html.as_ref(), "<main><strong>ok</strong></main>");
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>");
}
#[test]
@@ -223,6 +417,7 @@ fn param_names_format_generated_param_names() {
// req: codegen/003
let param = ParamName::new("todo_id");
assert_eq!(param.as_str(), "todo_id");
assert_eq!(param.as_ref(), "todo_id");
assert_eq!(param.to_string(), "todo_id");
}
@@ -231,6 +426,7 @@ fn component_refs_format_generated_component_names() {
// req: component/003
let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.as_ref(), "todo_list");
assert_eq!(component.to_string(), "todo_list");
}
@@ -301,4 +497,47 @@ fn build_fingerprint_is_deterministic_from_abi_parts() {
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(
BuildFingerprint::from_parts(&[]),
BuildFingerprint(0xcbf29ce484222325)
);
assert_eq!(a, BuildFingerprint(13725386680924731485));
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
// test req: abi/001 req: abi/002 req: abi/003
}
#[test]
fn public_token_and_form_error_adapters_preserve_values() {
const NOTICE: EventName = EventName::new("notice");
assert_eq!(NOTICE.as_str(), "notice");
assert_eq!(NOTICE.as_ref(), "notice");
assert_eq!(NOTICE.to_string(), "notice");
assert_eq!(String::from(NOTICE), "notice");
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
const ACTIVE: CssClass = CssClass::new("active");
assert_eq!(ACTIVE.as_str(), "active");
assert_eq!(ACTIVE.as_ref(), "active");
assert_eq!(ACTIVE.to_string(), "active");
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
assert_eq!(classes.as_ref(), "active selected");
let error = FormError::new("invalid email");
assert_eq!(error.message(), "invalid email");
assert_eq!(error.to_string(), "invalid email");
assert_eq!(u32::parse_form_value("42"), Ok(42));
assert_eq!(
u32::parse_form_value("nope"),
Err("invalid form value".into())
);
assert_eq!(
Form::<()>::new(7).reset(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: "7".into(),
}
);
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
}