diff --git a/crates/pack-guest-macros/src/lib.rs b/crates/pack-guest-macros/src/lib.rs index 811b925..67fda23 100644 --- a/crates/pack-guest-macros/src/lib.rs +++ b/crates/pack-guest-macros/src/lib.rs @@ -1740,7 +1740,22 @@ fn parse_func_sigs_into( sigs: &mut Vec, types: &[wit_parser::TypeDef], ) -> Result<(), String> { + // Typedefs declared inside this block (e.g. `record foo { ... }`) shadow + // and extend the outer `types` slice for ref resolution within the block. + let mut local_types: Vec = types.to_vec(); + while !parser.peek_is_symbol('}') && !parser.is_eof() { + // Try a typedef first — records/variants/etc declared inside an + // interface block are scoped to that block and resolve refs structurally. + if let Some(td) = wit_parser::try_parse_typedef_public(parser) + .map_err(|e| format!("type definition error: {}", e))? + { + local_types.push(td); + parser.accept_symbol(','); + parser.accept_symbol(';'); + continue; + } + let (iface, name) = parse_function_path(parser, interface)?; parser.expect_symbol(':').map_err(|e| e.to_string())?; parser.accept_ident("func"); @@ -1750,13 +1765,13 @@ fn parse_func_sigs_into( let params: Vec<(String, metadata::TypeDesc)> = func .params .iter() - .map(|(n, t)| (n.clone(), metadata::wit_type_to_type_desc(t, types))) + .map(|(n, t)| (n.clone(), metadata::wit_type_to_type_desc(t, &local_types))) .collect(); let results: Vec = func .results .iter() - .map(|t| metadata::wit_type_to_type_desc(t, types)) + .map(|t| metadata::wit_type_to_type_desc(t, &local_types)) .collect(); sigs.push(metadata::FuncSig { @@ -1771,3 +1786,148 @@ fn parse_func_sigs_into( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use packr_abi::{ + hash_function, hash_interface, hash_record, hash_result, Binding, HASH_STRING, + }; + + /// Parser accepts `record name { ... }` inside `imports { iface { ... } }`, + /// scoping the type to that interface block, and refs in subsequent function + /// signatures resolve to its structural hash. This is the original + /// agentry-actor blocker from the user report. + #[test] + fn parses_record_inside_imports_interface_block() { + let src = r#" + imports { + theater:simple/podman { + record container-spec { + image: string, + name: string, + } + + run: func(spec: container-spec) -> result + } + } + "#; + + // Should parse without error (this is the bug from agentry). + let bytes = parse_and_encode_metadata(src).expect("parse"); + assert!(!bytes.is_empty()); + + // Decoded metadata should carry an import-hash for the interface that + // matches the structural hash computed by hand. + let record_hash = hash_record(&[("image", HASH_STRING), ("name", HASH_STRING)]); + let func_hash = hash_function(&[record_hash], &[hash_result(&HASH_STRING, &HASH_STRING)]); + let expected_iface_hash = hash_interface( + "theater:simple/podman", + &[], + &[Binding { + name: "run", + hash: func_hash, + }], + ); + + // Re-derive via the public path used by the metadata encoder. + let mut sigs = Vec::new(); + let tokens = wit_parser::tokenize(src).expect("tokenize"); + let mut parser = wit_parser::make_parser(tokens); + parser.accept_ident("imports"); + parser.expect_symbol('{').expect("imports {"); + parse_import_sigs(&mut parser, &mut sigs, &[]).expect("import sigs"); + + let iface_hashes = metadata::compute_interface_hashes(&sigs); + assert_eq!(iface_hashes.len(), 1); + assert_eq!(iface_hashes[0].name, "theater:simple/podman"); + assert_eq!(iface_hashes[0].hash, expected_iface_hash); + } + + /// Records declared inside one interface block do not leak into a sibling + /// interface block's resolution scope. + #[test] + fn record_scope_does_not_leak_between_sibling_interfaces() { + let src = r#" + imports { + ns:pkg/a { + record shape { + a: string, + } + do-a: func(s: shape) -> string + } + ns:pkg/b { + // No `shape` typedef here — `shape` should remain unresolved. + do-b: func(s: shape) -> string + } + } + "#; + + let mut sigs = Vec::new(); + let tokens = wit_parser::tokenize(src).expect("tokenize"); + let mut parser = wit_parser::make_parser(tokens); + parser.accept_ident("imports"); + parser.expect_symbol('{').expect("imports {"); + parse_import_sigs(&mut parser, &mut sigs, &[]).expect("import sigs"); + + let iface_hashes = metadata::compute_interface_hashes(&sigs); + let h_a = iface_hashes.iter().find(|h| h.name == "ns:pkg/a").unwrap(); + let h_b = iface_hashes.iter().find(|h| h.name == "ns:pkg/b").unwrap(); + + // `a` resolves `shape` structurally; `b` falls back to opaque (HASH_SELF_REF + // via TypeDesc::Value). The hashes must differ. + assert_ne!(h_a.hash, h_b.hash); + } + + /// Top-level typedefs remain visible inside imports/exports interface blocks. + #[test] + fn top_level_typedef_visible_inside_interface_block() { + let src = r#" + record point { + x: s32, + y: s32, + } + + imports { + ns:pkg/geo { + move: func(p: point) -> point + } + } + "#; + let bytes = parse_and_encode_metadata(src).expect("parse"); + assert!(!bytes.is_empty()); + } + + /// `result<_, E>` ok-arm hashes as Bool (and `_` in err-arm as String) by + /// convention — see the comment in `pack/src/parser/pact.rs::parse_result`. + /// The actor side must match this exactly so hashes converge. + #[test] + fn result_underscore_uses_bool_string_convention() { + let src = r#" + imports { + ns:pkg/api { + do-nothing: func() -> result<_, string> + } + } + "#; + let mut sigs = Vec::new(); + let tokens = wit_parser::tokenize(src).expect("tokenize"); + let mut parser = wit_parser::make_parser(tokens); + parser.accept_ident("imports"); + parser.expect_symbol('{').expect("imports {"); + parse_import_sigs(&mut parser, &mut sigs, &[]).expect("import sigs"); + + use packr_abi::HASH_BOOL; + let func_hash = hash_function(&[], &[hash_result(&HASH_BOOL, &HASH_STRING)]); + let expected = hash_interface( + "ns:pkg/api", + &[], + &[Binding { + name: "do-nothing", + hash: func_hash, + }], + ); + let iface_hashes = metadata::compute_interface_hashes(&sigs); + assert_eq!(iface_hashes[0].hash, expected); + } +} diff --git a/crates/pack-guest-macros/src/metadata.rs b/crates/pack-guest-macros/src/metadata.rs index ae461c3..b107dde 100644 --- a/crates/pack-guest-macros/src/metadata.rs +++ b/crates/pack-guest-macros/src/metadata.rs @@ -408,6 +408,9 @@ pub fn wit_type_to_type_desc( TypeDesc::Option(Box::new(wit_type_to_type_desc(inner, types))) } crate::wit_parser::Type::Result { ok, err } => TypeDesc::Result { + // `_` maps to Bool for ok / String for err to match the handler-side + // pact parser's convention (see `parse_result` in pack/src/parser/pact.rs). + // Semantically odd, but the two sides must agree on the hash. ok: Box::new( ok.as_ref() .map_or(TypeDesc::Bool, |t| wit_type_to_type_desc(t, types)), diff --git a/src/interface_impl.rs b/src/interface_impl.rs index d5c35ab..321233b 100644 --- a/src/interface_impl.rs +++ b/src/interface_impl.rs @@ -24,8 +24,7 @@ use crate::metadata::TypeHash; use crate::parser::{MetadataValue, PactExport, PactInterface}; -use crate::types::Type; -use sha2::Digest; +use crate::types::{Type, TypeDef}; // ============================================================================ // PackType Trait - Maps Rust types to Pack types @@ -194,55 +193,30 @@ pub struct FuncSignature { } impl FuncSignature { - /// Compute the hash of this function signature. + /// Compute the hash of this function signature without resolving named refs. + /// + /// Equivalent to `hash_in(&[])`. Suitable for `InterfaceImpl::func`-built + /// signatures where types come from `PackType` (no `Type::Ref`s appear). pub fn hash(&self) -> TypeHash { - // Convert Type to TypeHash for each param/result - let param_hashes: Vec<_> = self.params.iter().map(type_to_hash).collect(); - let result_hashes: Vec<_> = self.results.iter().map(type_to_hash).collect(); - - crate::metadata::hash_function(¶m_hashes, &result_hashes) + self.hash_in(&[]) } -} -/// Convert a Pack Type to a TypeHash. -fn type_to_hash(ty: &Type) -> TypeHash { - use crate::metadata::*; - - match ty { - Type::Bool => HASH_BOOL, - Type::U8 => HASH_U8, - Type::U16 => HASH_U16, - Type::U32 => HASH_U32, - Type::U64 => HASH_U64, - Type::S8 => HASH_S8, - Type::S16 => HASH_S16, - Type::S32 => HASH_S32, - Type::S64 => HASH_S64, - Type::F32 => HASH_F32, - Type::F64 => HASH_F64, - Type::Char => HASH_CHAR, - Type::String => HASH_STRING, - Type::Unit => hash_tuple(&[]), // Unit is empty tuple - Type::List(inner) => hash_list(&type_to_hash(inner)), - Type::Option(inner) => hash_option(&type_to_hash(inner)), - Type::Result { ok, err } => hash_result(&type_to_hash(ok), &type_to_hash(err)), - Type::Tuple(items) => { - let hashes: Vec<_> = items.iter().map(type_to_hash).collect(); - hash_tuple(&hashes) - } - // Type::Ref references a named type - for host functions using PackType, - // we won't encounter these since PackType maps Rust types to inline types. - // If we do encounter a Ref, use the path name to compute a placeholder hash. - Type::Ref(path) => { - // Named type reference - hash based on the path string - let path_str = path.to_string(); - let mut hasher = sha2::Sha256::new(); - hasher.update(b"ref:"); - hasher.update(path_str.as_bytes()); - TypeHash::from_bytes(hasher.finalize().into()) - } - // Dynamic value type - use HASH_SELF_REF for consistency with pack-guest-macros - Type::Value => HASH_SELF_REF, + /// Compute the hash, resolving named refs against `types`. + /// + /// Used by `from_pact`-built interfaces where function signatures may + /// reference records/variants declared at the pact-interface level. + pub fn hash_in(&self, types: &[TypeDef]) -> TypeHash { + let param_hashes: Vec<_> = self + .params + .iter() + .map(|t| crate::metadata::hash_type_in(t, types)) + .collect(); + let result_hashes: Vec<_> = self + .results + .iter() + .map(|t| crate::metadata::hash_type_in(t, types)) + .collect(); + crate::metadata::hash_function(¶m_hashes, &result_hashes) } } @@ -258,6 +232,12 @@ fn type_to_hash(ty: &Type) -> TypeHash { pub struct InterfaceImpl { /// The interface name (e.g., "theater:simple/runtime") pub name: String, + /// Type definitions in scope for ref resolution when hashing. + /// + /// Populated by `from_pact` from `pact.types`. Manually-constructed + /// interfaces (via `new` + `func`) leave this empty — their signatures + /// come from `PackType` and don't contain `Type::Ref`s. + pub types: Vec, /// Function signatures (extracted from Rust types) pub functions: Vec, } @@ -267,6 +247,7 @@ impl InterfaceImpl { pub fn new(name: impl Into) -> Self { Self { name: name.into(), + types: Vec::new(), functions: Vec::new(), } } @@ -306,7 +287,7 @@ impl InterfaceImpl { .iter() .map(|f| Binding { name: &f.name, - hash: f.hash(), + hash: f.hash_in(&self.types), }) .collect(); bindings.sort_by(|a, b| a.name.cmp(b.name)); @@ -334,7 +315,7 @@ impl InterfaceImpl { let func = self.functions.iter().find(|f| f.name == *name)?; bindings.push(Binding { name: &func.name, - hash: func.hash(), + hash: func.hash_in(&self.types), }); } @@ -355,7 +336,7 @@ impl InterfaceImpl { self.functions .iter() .find(|f| f.name == name) - .map(|f| f.hash()) + .map(|f| f.hash_in(&self.types)) } /// Get the interface name. @@ -401,6 +382,18 @@ impl InterfaceImpl { let mut interface = InterfaceImpl::new(full_name); + // Capture interface-level typedefs so refs in function signatures + // (e.g. `func run(spec: container-spec) -> ...`) resolve structurally + // when the interface hash is computed. + interface.types = pact.types.clone(); + // Type-style exports (`PactExport::Type`) also contribute to the + // resolution scope alongside the inline `record/variant/...` defs. + for export in &pact.exports { + if let PactExport::Type(td) = export { + interface.types.push(td.clone()); + } + } + // Extract function signatures from exports for export in &pact.exports { if let PactExport::Function(func) = export { diff --git a/src/lib.rs b/src/lib.rs index 279afce..22eecb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,11 +64,12 @@ pub use interface_impl::{FuncSignature, HostFunc, InterfaceImpl, PackParams, Pac pub use metadata::{ compute_interface_hash, compute_interface_hashes, decode_metadata, decode_metadata_with_hashes, encode_metadata, encode_metadata_with_hashes, hash_function, hash_function_from_sig, - hash_interface, hash_list, hash_option, hash_record, hash_result, hash_tuple, hash_type, - hash_variant, validate_value_in_type_space, Binding, CaseDesc, FieldDesc, FunctionSignature, - InterfaceHash, MetadataError, MetadataWithHashes, PackageMetadata, ParamSignature, TypeDesc, - TypeHash, TypeValidationError, HASH_BOOL, HASH_CHAR, HASH_F32, HASH_F64, HASH_FLAGS, HASH_S16, - HASH_S32, HASH_S64, HASH_S8, HASH_STRING, HASH_U16, HASH_U32, HASH_U64, HASH_U8, + hash_function_from_sig_in, hash_interface, hash_list, hash_option, hash_record, hash_result, + hash_tuple, hash_type, hash_type_in, hash_variant, validate_value_in_type_space, Binding, + CaseDesc, FieldDesc, FunctionSignature, InterfaceHash, MetadataError, MetadataWithHashes, + PackageMetadata, ParamSignature, TypeDesc, TypeHash, TypeValidationError, HASH_BOOL, HASH_CHAR, + HASH_F32, HASH_F64, HASH_FLAGS, HASH_S16, HASH_S32, HASH_S64, HASH_S8, HASH_STRING, HASH_U16, + HASH_U32, HASH_U64, HASH_U8, }; pub use parser::{ parse_pact, parse_pact_dir, parse_pact_dir_with_registry, parse_pact_file, Interface, diff --git a/src/metadata.rs b/src/metadata.rs index 8f7f1bd..cbfb322 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -354,8 +354,25 @@ pub struct InterfaceHash { /// Compute the TypeHash for a Type from the types module. /// -/// This is used when computing interface hashes from Arena metadata. +/// Named references (`Type::Ref`) are resolved structurally when this is called +/// via the in-scope variants. This bare entry point has no resolution context, +/// so it falls back to a path-based hash for refs — useful only for primitive +/// or fully-monomorphic types. pub fn hash_type(ty: &Type) -> TypeHash { + hash_type_in(ty, &[]) +} + +/// Compute the TypeHash for a Type, resolving named refs against `types`. +/// +/// When a `Type::Ref` names a local typedef, the underlying record/variant/etc +/// is hashed structurally — matching the actor-side `pack_types!` macro which +/// inlines refs at metadata-emission time. Self-references and cycles return +/// `HASH_SELF_REF`. +pub fn hash_type_in(ty: &Type, types: &[TypeDef]) -> TypeHash { + hash_type_inner(ty, types, &mut Vec::new()) +} + +fn hash_type_inner(ty: &Type, types: &[TypeDef], stack: &mut Vec) -> TypeHash { match ty { Type::Unit => hash_tuple(&[]), // Unit is empty tuple Type::Bool => HASH_BOOL, @@ -371,47 +388,133 @@ pub fn hash_type(ty: &Type) -> TypeHash { Type::F64 => HASH_F64, Type::Char => HASH_CHAR, Type::String => HASH_STRING, - Type::List(inner) => hash_list(&hash_type(inner)), - Type::Option(inner) => hash_option(&hash_type(inner)), - Type::Result { ok, err } => hash_result(&hash_type(ok), &hash_type(err)), - Type::Tuple(types) => { - let hashes: Vec<_> = types.iter().map(hash_type).collect(); + Type::List(inner) => hash_list(&hash_type_inner(inner, types, stack)), + Type::Option(inner) => hash_option(&hash_type_inner(inner, types, stack)), + Type::Result { ok, err } => hash_result( + &hash_type_inner(ok, types, stack), + &hash_type_inner(err, types, stack), + ), + Type::Tuple(elems) => { + let hashes: Vec<_> = elems + .iter() + .map(|t| hash_type_inner(t, types, stack)) + .collect(); hash_tuple(&hashes) } - Type::Ref(path) => { - // Named type reference - hash based on the path string - let path_str = path.to_string(); - let mut hasher = sha2::Sha256::new(); - hasher.update(b"ref:"); - hasher.update(path_str.as_bytes()); - TypeHash::from_bytes(hasher.finalize().into()) + Type::Ref(path) => hash_ref(path, types, stack), + Type::Value => HASH_SELF_REF, + } +} + +fn hash_ref(path: &TypePath, types: &[TypeDef], stack: &mut Vec) -> TypeHash { + // Explicit self-reference: `self` in a recursive type definition. + if path.is_self_ref() { + return HASH_SELF_REF; + } + + // Simple named ref: try to resolve against the in-scope typedefs. + if let Some(name) = path.as_simple() { + // Cycle: this name is already being hashed further up the stack. + if stack.iter().any(|s| s == name) { + return HASH_SELF_REF; } - Type::Value => { - // Dynamic value type - use HASH_SELF_REF for consistency with pack-guest-macros - HASH_SELF_REF + if let Some(td) = types.iter().find(|t| t.name() == name) { + stack.push(name.to_string()); + let h = hash_typedef_inner(td, types, stack); + stack.pop(); + return h; } } + + // Unresolved or qualified path: fall back to a path-based hash so refs + // to types we can't see still produce a stable (if nominal) hash. + let path_str = path.to_string(); + let mut hasher = sha2::Sha256::new(); + hasher.update(b"ref:"); + hasher.update(path_str.as_bytes()); + TypeHash::from_bytes(hasher.finalize().into()) } -/// Compute the hash for a function from the types module. +fn hash_typedef_inner(td: &TypeDef, types: &[TypeDef], stack: &mut Vec) -> TypeHash { + match td { + TypeDef::Alias { ty, .. } => hash_type_inner(ty, types, stack), + TypeDef::Record { fields, .. } => { + // Collect (name, hash) pairs, sort by name for canonical ordering. + let pairs: Vec<(String, TypeHash)> = fields + .iter() + .map(|f| (f.name.clone(), hash_type_inner(&f.ty, types, stack))) + .collect(); + let mut sorted: Vec<_> = pairs.iter().map(|(n, h)| (n.as_str(), *h)).collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + hash_record(&sorted) + } + TypeDef::Variant { cases, .. } => { + // Unit payloads → None (matches actor-side `Option` shape). + let pairs: Vec<(String, Option)> = cases + .iter() + .map(|c| { + let payload = if c.payload.is_unit() { + None + } else { + Some(hash_type_inner(&c.payload, types, stack)) + }; + (c.name.clone(), payload) + }) + .collect(); + let mut sorted: Vec<_> = pairs.iter().map(|(n, h)| (n.as_str(), *h)).collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + hash_variant(&sorted) + } + TypeDef::Enum { cases, .. } => { + // Enum hashes as a variant with all-None payloads (matches actor side). + let mut sorted: Vec<(&str, Option)> = + cases.iter().map(|c| (c.as_str(), None)).collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + hash_variant(&sorted) + } + TypeDef::Flags { .. } => HASH_FLAGS, + } +} + +/// Compute the hash for a function signature with no in-scope typedefs. +/// +/// Equivalent to `hash_function_from_sig_in(func, &[])`. Refs resolve nominally. pub fn hash_function_from_sig(func: &Function) -> TypeHash { - let param_hashes: Vec<_> = func.params.iter().map(|p| hash_type(&p.ty)).collect(); - let result_hashes: Vec<_> = func.results.iter().map(hash_type).collect(); + hash_function_from_sig_in(func, &[]) +} + +/// Compute the hash for a function signature, resolving refs against `types`. +pub fn hash_function_from_sig_in(func: &Function, types: &[TypeDef]) -> TypeHash { + let param_hashes: Vec<_> = func + .params + .iter() + .map(|p| hash_type_in(&p.ty, types)) + .collect(); + let result_hashes: Vec<_> = func + .results + .iter() + .map(|t| hash_type_in(t, types)) + .collect(); hash_function(¶m_hashes, &result_hashes) } /// Compute the interface hash for an Arena containing functions. /// -/// The Arena is treated as an interface - its name and function signatures -/// are hashed to produce a content-addressed interface hash. +/// The Arena is treated as an interface — its name, in-scope type definitions, +/// and function signatures are hashed to produce a content-addressed +/// interface hash. Named refs in function signatures resolve structurally +/// against `interface_arena.types`. pub fn compute_interface_hash(interface_arena: &Arena) -> TypeHash { + // Resolve refs against this interface's own typedefs. + let types: &[TypeDef] = &interface_arena.types; + // Create bindings for each function (sorted by name for determinism) let mut bindings: Vec<_> = interface_arena .functions .iter() .map(|f| Binding { name: &f.name, - hash: hash_function_from_sig(f), + hash: hash_function_from_sig_in(f, types), }) .collect(); bindings.sort_by(|a, b| a.name.cmp(b.name)); @@ -2188,4 +2291,83 @@ mod tests { }; assert!(validate_value_in_type_space(&val_bad_list, &ty_list, &defs).is_err()); } + + #[test] + fn test_hash_type_ref_resolves_structurally() { + // A ref to a known record hashes the same as the record's structure. + let types = vec![TypeDef::record( + "point", + vec![Field::new("x", Type::S32), Field::new("y", Type::S32)], + )]; + + let ref_hash = hash_type_in(&Type::named("point"), &types); + let structural_hash = + hash_record(&[("x", hash_type(&Type::S32)), ("y", hash_type(&Type::S32))]); + assert_eq!(ref_hash, structural_hash); + } + + #[test] + fn test_hash_type_ref_unresolved_falls_back_to_path() { + // Without typedefs in scope, a ref hashes by path (legacy behavior). + let h1 = hash_type_in(&Type::named("unknown-type"), &[]); + let h2 = hash_type(&Type::named("unknown-type")); + assert_eq!(h1, h2); + // And it's distinct from any primitive. + assert_ne!(h1, HASH_STRING); + } + + #[test] + fn test_hash_type_recursive_ref_terminates() { + // record tree { children: list } — recursive without an explicit self-ref. + let types = vec![TypeDef::record( + "tree", + vec![Field::new("children", Type::list(Type::named("tree")))], + )]; + + // Should terminate and produce a stable hash. + let h = hash_type_in(&Type::named("tree"), &types); + let again = hash_type_in(&Type::named("tree"), &types); + assert_eq!(h, again); + + // The inner `list` recurses into the in-progress name and returns + // HASH_SELF_REF, equivalent to `list` inside the record body. + let expected = hash_record(&[("children", hash_list(&HASH_SELF_REF))]); + assert_eq!(h, expected); + } + + #[test] + fn test_compute_interface_hash_resolves_record_refs() { + // An interface with a record + a function referencing it should hash + // identically whether we reference the record by name or inline its + // structure into the function signature. + let mut by_ref = Arena::new("test:api/podman"); + by_ref.add_type(TypeDef::record( + "container-spec", + vec![ + Field::new("image", Type::String), + Field::new("name", Type::String), + ], + )); + by_ref.add_function(Function::with_signature( + "run", + vec![Param::new("spec", Type::named("container-spec"))], + vec![Type::result(Type::String, Type::String)], + )); + + // Equivalent interface with the record inlined as a tuple (or hashed + // structurally directly). We construct the expected hash by hand using + // the structural hash primitives. + let record_hash = hash_record(&[("image", HASH_STRING), ("name", HASH_STRING)]); + let func_hash = hash_function(&[record_hash], &[hash_result(&HASH_STRING, &HASH_STRING)]); + let expected = hash_interface( + "test:api/podman", + &[], + &[Binding { + name: "run", + hash: func_hash, + }], + ); + + assert_eq!(compute_interface_hash(&by_ref), expected); + } } diff --git a/tests/handler_actor_hash_convergence.rs b/tests/handler_actor_hash_convergence.rs new file mode 100644 index 0000000..be564f5 --- /dev/null +++ b/tests/handler_actor_hash_convergence.rs @@ -0,0 +1,98 @@ +//! Convergence test: handler-side and actor-side hashing of a record-using +//! interface must produce byte-identical interface hashes. +//! +//! The handler side parses .pact source via `parse_pact`, builds an +//! `InterfaceImpl::from_pact`, and reports its hash — this is what theater +//! does at handler-registration time. The actor side (proc-macro) computes +//! its hash through `packr_abi::hash_*`. This test verifies that both +//! pipelines produce the same bytes when given the same interface definition, +//! which is the property theater checks before wiring an actor to a handler. +//! +//! Together these cover the agentry-actor + theater-handler-podman bug: +//! the actor declares records inside `imports { theater:simple/podman { ... } }` +//! and the resulting hash matches the handler-side hash of the same interface +//! defined in podman.pact. + +use packr::{parse_pact, InterfaceImpl}; + +#[test] +fn handler_hash_matches_actor_hash_for_record_interface() { + // The exact shape from theater-handler-podman/podman.pact, trimmed to two + // functions for clarity. Records live inside the interface block. + let src = r#" + interface podman { + @package: string = "theater:simple" + + record mount-spec { + source: string, + target: string, + read-only: bool, + } + + record container-spec { + image: string, + name: string, + mounts: list, + } + + exports { + run: func(spec: container-spec) -> result + stop: func(name: string) -> result<_, string> + } + } + "#; + + // Handler side: the path theater actually takes — parse pact, build + // InterfaceImpl, ask for its hash. + let pact = parse_pact(src).expect("parse pact"); + let iface = InterfaceImpl::from_pact(&pact); + let handler_hash = iface.hash(); + + // Actor side: compute the hash directly from the structural primitives + // exposed by packr-abi, which is exactly what pack_types! does at + // proc-macro time (`packr_guest_macros::metadata::compute_interface_hashes`). + use packr_abi::{ + hash_function, hash_interface, hash_list, hash_record, hash_result, Binding, HASH_BOOL, + HASH_STRING, + }; + + let mount_spec_hash = hash_record(&[ + ("read-only", HASH_BOOL), + ("source", HASH_STRING), + ("target", HASH_STRING), + ]); + let container_spec_hash = hash_record(&[ + ("image", HASH_STRING), + ("mounts", hash_list(&mount_spec_hash)), + ("name", HASH_STRING), + ]); + + let run_hash = hash_function( + &[container_spec_hash], + &[hash_result(&HASH_STRING, &HASH_STRING)], + ); + // `result<_, string>` — by convention, `_` in ok position hashes as Bool + // and in err position as String (see pact.rs::parse_result). + let stop_hash = hash_function(&[HASH_STRING], &[hash_result(&HASH_BOOL, &HASH_STRING)]); + + let mut bindings = vec![ + Binding { + name: "run", + hash: run_hash, + }, + Binding { + name: "stop", + hash: stop_hash, + }, + ]; + bindings.sort_by(|a, b| a.name.cmp(b.name)); + + let actor_hash = hash_interface("theater:simple/podman", &[], &bindings); + + // Convergence: byte-equal across the two hashers. + assert_eq!( + handler_hash.as_bytes(), + actor_hash.as_bytes(), + "handler hash and actor hash must agree for record-using interfaces" + ); +}