Skip to content

Commit 88ebb5d

Browse files
alexytsuabright
andauthored
fvm_dispatch improvements (#5)
* update gitignore * Renaming and docstrings * added a workspace file * fvm_dispatch: handle hash collisions and invalid method names * clippy fixes * Update fvm_dispatch/src/message.rs Co-authored-by: Andy Bright <[email protected]> * Update fvm_dispatch/src/hash.rs Co-authored-by: Andy Bright <[email protected]> * add thiserror macro * use thiserror for defining errors more concisely * implement as_u32 with from_be_bytes * fix failing unit tests Co-authored-by: Andy Bright <[email protected]>
1 parent fd4dabb commit 88ebb5d

File tree

5 files changed

+147
-38
lines changed

5 files changed

+147
-38
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ Cargo.lock
88

99
# These are backup files generated by rustfmt
1010
**/*.rs.bk
11+
12+
# IDE specific user-config
13+
.vscode/

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[workspace]
2+
3+
members = [
4+
"fvm_dispatch",
5+
]

fvm_dispatch/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ edition = "2021"
88
[dependencies]
99
fvm_ipld_encoding = { version = "0.2.2" }
1010
fvm_sdk = { version = "1.0.0" }
11-
fvm_shared = { version = "0.8.0" }
11+
fvm_shared = { version = "0.8.0" }
12+
thiserror = { version = "1.0.31" }

fvm_dispatch/src/hash.rs

Lines changed: 102 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use thiserror::Error;
2+
13
use fvm_sdk::crypto;
24

35
/// Minimal interface for a hashing function
46
///
5-
/// Hasher::hash() must return a digest that is at least 4 bytes long so that it can be cast to a u32
7+
/// Hasher::hash() must return a digest that is at least 4 bytes long so that it can be cast to a
8+
/// u32
69
pub trait Hasher {
710
/// For an input of bytes return a digest that is at least 4 bytes long
811
fn hash(&self, bytes: &[u8]) -> Vec<u8>;
@@ -18,45 +21,84 @@ impl Hasher for Blake2bSyscall {
1821
}
1922
}
2023

24+
/// Uses an underlying hashing function (blake2b by convention) to generate method numbers from
25+
/// method names
2126
#[derive(Default)]
22-
pub struct MethodHasher<T: Hasher> {
27+
pub struct MethodResolver<T: Hasher> {
2328
hasher: T,
2429
}
2530

26-
impl<T: Hasher> MethodHasher<T> {
31+
#[derive(Error, PartialEq, Debug)]
32+
pub enum MethodNameErr {
33+
#[error("empty method name provided")]
34+
EmptyString,
35+
#[error("illegal symbol used in method name")]
36+
IllegalSymbol,
37+
#[error("unable to calculate method id, choose a another method name")]
38+
IndeterminableId,
39+
}
40+
41+
impl<T: Hasher> MethodResolver<T> {
2742
const CONSTRUCTOR_METHOD_NAME: &'static str = "Constructor";
2843
const CONSTRUCTOR_METHOD_NUMBER: u64 = 1_u64;
44+
const RESERVED_METHOD_NUMBER: u64 = 0_u64;
45+
46+
/// Creates a MethodResolver with an instance of a hasher (blake2b by convention)
2947
pub fn new(hasher: T) -> Self {
3048
Self { hasher }
3149
}
3250

33-
pub fn method_number(&self, method_name: &str) -> u64 {
51+
/// Generates a standard FRC-XXX compliant method number
52+
///
53+
/// The method number is calculated as the first four bytes of `hash(method-name)`.
54+
/// The name `Constructor` is always hashed to 1 and other method names that hash to
55+
/// 0 or 1 are avoided via rejection sampling.
56+
///
57+
///
58+
pub fn method_number(&self, method_name: &str) -> Result<u64, MethodNameErr> {
59+
// TODO: sanitise method_name before checking (or reject invalid whitespace)
60+
if method_name.contains('|') {
61+
return Err(MethodNameErr::IllegalSymbol);
62+
}
63+
64+
if method_name.is_empty() {
65+
return Err(MethodNameErr::EmptyString);
66+
}
67+
3468
if method_name == Self::CONSTRUCTOR_METHOD_NAME {
35-
Self::CONSTRUCTOR_METHOD_NUMBER
36-
} else {
37-
let digest = self.hasher.hash(method_name.as_bytes());
38-
if digest.len() < 4 {
39-
panic!("Invalid hasher used: digest must be at least 4 bytes long");
69+
return Ok(Self::CONSTRUCTOR_METHOD_NUMBER);
70+
}
71+
let mut digest = self.hasher.hash(method_name.as_bytes());
72+
while digest.len() >= 4 {
73+
let method_id = as_u32(digest.as_slice()) as u64;
74+
if method_id != Self::CONSTRUCTOR_METHOD_NUMBER
75+
&& method_id != Self::RESERVED_METHOD_NUMBER
76+
{
77+
return Ok(method_id);
78+
} else {
79+
digest.remove(0);
4080
}
41-
as_u32(digest.as_slice()) as u64
4281
}
82+
Err(MethodNameErr::IndeterminableId)
4383
}
4484
}
4585

4686
/// Takes a byte array and interprets it as a u32 number
47-
/// Assumes little-endian order
48-
#[rustfmt::skip]
87+
///
88+
/// Using big-endian order interperets the first four bytes to an int.
89+
/// The slice passed to this must be at least length 4
4990
fn as_u32(bytes: &[u8]) -> u32 {
50-
((bytes[0] as u32) << (8 * 3)) +
51-
((bytes[1] as u32) << (8 * 2)) +
52-
((bytes[2] as u32) << (8 * 1)) +
53-
(bytes[3] as u32)
91+
u32::from_be_bytes(
92+
bytes[0..4]
93+
.try_into()
94+
.expect("bytes was not at least length 4"),
95+
)
5496
}
5597

5698
#[cfg(test)]
5799
mod tests {
58100

59-
use super::{Blake2bSyscall, Hasher, MethodHasher};
101+
use super::{Hasher, MethodNameErr, MethodResolver};
60102

61103
#[derive(Clone, Copy)]
62104
struct FakeHasher {}
@@ -67,24 +109,56 @@ mod tests {
67109
}
68110

69111
#[test]
70-
#[allow(unused)]
71-
fn compile() {
72-
let method_hasher = MethodHasher::new(Blake2bSyscall {});
112+
fn constructor_is_1() {
113+
let method_hasher = MethodResolver::new(FakeHasher {});
114+
assert_eq!(method_hasher.method_number("Constructor").unwrap(), 1);
73115
}
74116

75117
#[test]
76-
fn constructor_method_number() {
77-
let method_hasher = MethodHasher::new(FakeHasher {});
78-
assert_eq!(method_hasher.method_number("Constructor"), 1);
118+
fn normal_method_is_hashed() {
119+
let fake_hasher = FakeHasher {};
120+
let method_hasher = MethodResolver::new(fake_hasher);
121+
assert_eq!(
122+
method_hasher.method_number("NormalMethod").unwrap(),
123+
super::as_u32(&fake_hasher.hash(b"NormalMethod")) as u64
124+
);
125+
126+
assert_eq!(
127+
method_hasher.method_number("NormalMethod2").unwrap(),
128+
super::as_u32(&fake_hasher.hash(b"NormalMethod2")) as u64
129+
);
79130
}
80131

81132
#[test]
82-
fn normal_method_number() {
83-
let fake_hasher = FakeHasher {};
84-
let method_hasher = MethodHasher::new(fake_hasher);
133+
fn disallows_invalid_method_names() {
134+
let method_hasher = MethodResolver::new(FakeHasher {});
85135
assert_eq!(
86-
method_hasher.method_number("NormalMethod"),
87-
super::as_u32(&fake_hasher.hash(b"NormalMethod")) as u64
136+
method_hasher.method_number("Invalid|Method").unwrap_err(),
137+
MethodNameErr::IllegalSymbol
88138
);
139+
assert_eq!(
140+
method_hasher.method_number("").unwrap_err(),
141+
MethodNameErr::EmptyString
142+
);
143+
}
144+
145+
#[test]
146+
fn avoids_disallowed_method_numbers() {
147+
let hasher = FakeHasher {};
148+
let method_hasher = MethodResolver::new(hasher);
149+
150+
// This simulates a method name that would hash to 0
151+
let contrived_0 = "\0\0\0\0MethodName";
152+
let contrived_0_digest = hasher.hash(contrived_0.as_bytes());
153+
assert_eq!(super::as_u32(&contrived_0_digest), 0);
154+
// But the method number is not a collision
155+
assert_ne!(method_hasher.method_number(contrived_0).unwrap(), 0);
156+
157+
// This simulates a method name that would hash to 1
158+
let contrived_1 = "\0\0\0\x01MethodName";
159+
let contrived_1_digest = hasher.hash(contrived_1.as_bytes());
160+
assert_eq!(super::as_u32(&contrived_1_digest), 1);
161+
// But the method number is not a collision
162+
assert_ne!(method_hasher.method_number(contrived_1).unwrap(), 1);
89163
}
90164
}

fvm_dispatch/src/message.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,55 @@
1-
use crate::hash::{Hasher, MethodHasher};
1+
use thiserror::Error;
2+
3+
use crate::hash::{Hasher, MethodNameErr, MethodResolver};
24

35
use fvm_ipld_encoding::RawBytes;
4-
use fvm_sdk::{send, SyscallResult};
6+
use fvm_sdk::{send, sys::ErrorNumber};
57
use fvm_shared::{address::Address, econ::TokenAmount, receipt::Receipt};
68

9+
/// Utility to invoke standard methods on deployed actors
710
#[derive(Default)]
8-
pub struct MethodDispatcher<T: Hasher> {
9-
method_hasher: MethodHasher<T>,
11+
pub struct MethodMessenger<T: Hasher> {
12+
method_resolver: MethodResolver<T>,
13+
}
14+
15+
#[derive(Error, PartialEq, Debug)]
16+
pub enum MethodMessengerError {
17+
#[error("error when calculating method name: `{0}`")]
18+
MethodName(MethodNameErr),
19+
#[error("error sending message: `{0}`")]
20+
Syscall(ErrorNumber),
21+
}
22+
23+
impl From<ErrorNumber> for MethodMessengerError {
24+
fn from(e: ErrorNumber) -> Self {
25+
Self::Syscall(e)
26+
}
27+
}
28+
29+
impl From<MethodNameErr> for MethodMessengerError {
30+
fn from(e: MethodNameErr) -> Self {
31+
Self::MethodName(e)
32+
}
1033
}
1134

12-
impl<T: Hasher> MethodDispatcher<T> {
35+
impl<T: Hasher> MethodMessenger<T> {
36+
/// Creates a new method messenger using a specified hashing function (blake2b by default)
1337
pub fn new(hasher: T) -> Self {
1438
Self {
15-
method_hasher: MethodHasher::new(hasher),
39+
method_resolver: MethodResolver::new(hasher),
1640
}
1741
}
1842

43+
/// Calls a method (by name) on a specified actor by constructing and publishing the underlying
44+
/// on-chain Message
1945
pub fn call_method(
2046
&self,
2147
to: &Address,
2248
method: &str,
2349
params: RawBytes,
2450
value: TokenAmount,
25-
) -> SyscallResult<Receipt> {
26-
let method = self.method_hasher.method_number(method);
27-
send::send(to, method, params, value)
51+
) -> Result<Receipt, MethodMessengerError> {
52+
let method = self.method_resolver.method_number(method)?;
53+
send::send(to, method, params, value).map_err(MethodMessengerError::from)
2854
}
2955
}

0 commit comments

Comments
 (0)