diff --git a/Cargo.lock b/Cargo.lock index 86af27919..b4af93835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6595,11 +6595,15 @@ dependencies = [ "sails-idl-meta", "sails-macros", "sails-reflect-hash", + "sails-storage", "sails-type-registry", "spin 0.10.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -6614,6 +6618,10 @@ dependencies = [ "serde", ] +[[package]] +name = "sails-storage" +version = "1.0.0-beta.5" + [[package]] name = "sails-type-registry" version = "1.0.0-beta.5" diff --git a/Cargo.toml b/Cargo.toml index 3a094df72..2affc523f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "rs/macros/core", "rs/reflect-hash", "rs/sol-gen", + "rs/storage", "rs/type-registry", "rs/type-registry/derive", "benchmarks/alloc-stress", @@ -74,6 +75,7 @@ sails-macros-core = { path = "rs/macros/core" } sails-macros = { path = "rs/macros" } sails-reflect-hash-derive = { path = "rs/reflect-hash/derive" } sails-reflect-hash = { path = "rs/reflect-hash" } +sails-storage = { path = "rs/storage" } sails-rs = { path = "rs" } sails-sol-gen = { path = "rs/sol-gen" } sails-type-registry = { path = "rs/type-registry" } diff --git a/rs/Cargo.toml b/rs/Cargo.toml index c9f5d2104..91ed7d8f1 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -34,10 +34,13 @@ sails-idl-gen = { workspace = true, optional = true } sails-idl-meta.workspace = true sails-macros.workspace = true sails-reflect-hash.workspace = true +sails-storage = { workspace = true, optional = true } sails-type-registry = { workspace = true, features = ["derive", "gprimitives"] } spin.workspace = true thiserror.workspace = true tokio-stream = { workspace = true, optional = true } +wasm-encoder = { workspace = true, features = ["wasmparser"], optional = true } +wasmparser = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] gear-core = { workspace = true, optional = true } # Needed for compilation of gstd in native mode @@ -46,6 +49,7 @@ gtest = { workspace = true, optional = true } log = { workspace = true, optional = true } [dev-dependencies] +tempfile.workspace = true tokio = { workspace = true, features = ["rt", "macros"] } [features] @@ -72,4 +76,4 @@ client-builder = ["std", "idl-gen", "idl-embed", "dep:sails-client-gen-v2", "dep idl-embed = ["dep:sails-idl-embed"] mockall = ["std", "dep:mockall"] std = ["futures/std", "sails-idl-gen?/std", "gear-core?/std"] -wasm-builder = ["dep:gwasm-builder"] +wasm-builder = ["dep:gwasm-builder", "dep:sails-storage", "dep:wasm-encoder", "dep:wasmparser"] diff --git a/rs/ethexe/Cargo.lock b/rs/ethexe/Cargo.lock index a2dd46d39..dad4c68ff 100644 --- a/rs/ethexe/Cargo.lock +++ b/rs/ethexe/Cargo.lock @@ -51,7 +51,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -85,9 +85,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy-primitives" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "bytes", "cfg-if", @@ -97,14 +97,14 @@ dependencies = [ "paste", "ruint", "rustc-hash 2.1.2", - "sha3", + "sha3 0.11.0", ] [[package]] name = "alloy-sol-macro" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", @@ -116,9 +116,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" dependencies = [ "alloy-sol-macro-input", "const-hex", @@ -127,16 +127,16 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.117", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cef806ad22d4392c5fc83cf8f2089f988eb99c7067b4e0c6f1971fc1cca318" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" dependencies = [ "const-hex", "dunce", @@ -150,9 +150,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" dependencies = [ "alloy-primitives", "alloy-sol-macro", @@ -419,7 +419,7 @@ checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" dependencies = [ "rustc-hash 2.1.2", "unicode-ident", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -597,6 +597,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bounded-collections" version = "0.2.4" @@ -633,7 +642,7 @@ dependencies = [ "parity-scale-codec", "scale-info", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", ] [[package]] @@ -786,9 +795,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -1106,6 +1115,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto-mac" version = "0.8.0" @@ -1214,9 +1232,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1345,10 +1363,20 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1582,9 +1610,9 @@ dependencies = [ [[package]] name = "enumset" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" dependencies = [ "enumset_derive", ] @@ -1729,13 +1757,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -2731,6 +2758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2746,9 +2774,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -2818,6 +2846,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3023,7 +3060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3134,6 +3171,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "keccak-const" version = "0.2.0" @@ -3204,18 +3251,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags 2.11.1", - "libc", - "plain", - "redox_syscall 0.7.5", -] - [[package]] name = "libsecp256k1" version = "0.7.2" @@ -3349,9 +3384,9 @@ dependencies = [ [[package]] name = "macro-string" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", @@ -3531,7 +3566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", - "keccak", + "keccak 0.1.6", "rand_core 0.6.4", "zeroize", ] @@ -3675,9 +3710,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-format" @@ -3882,7 +3917,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -3963,12 +3998,6 @@ dependencies = [ "spki", ] -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "polkavm-common" version = "0.9.0" @@ -4347,15 +4376,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -4459,7 +4479,7 @@ checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" dependencies = [ "bytecheck 0.8.2", "bytes", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap 2.14.0", "munge", "ptr_meta 0.3.1", @@ -4689,7 +4709,7 @@ dependencies = [ "gstd", "gsys", "gtest", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "hex", "keccak-const", "log", @@ -4700,12 +4720,19 @@ dependencies = [ "sails-idl-meta", "sails-macros", "sails-reflect-hash", + "sails-storage", "sails-type-registry", "spin 0.10.0", "thiserror 2.0.18", "tokio-stream", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] +[[package]] +name = "sails-storage" +version = "1.0.0-beta.5" + [[package]] name = "sails-type-registry" version = "1.0.0-beta.5" @@ -5074,7 +5101,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -5304,7 +5341,7 @@ dependencies = [ "byteorder", "digest 0.10.7", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "twox-hash", ] @@ -5824,9 +5861,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f425ae0b12e2f5ae65542e00898d500d4d318b4baf09f40fd0d410454e9947" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" dependencies = [ "paste", "proc-macro2", @@ -5853,9 +5890,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -6012,9 +6049,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -6073,7 +6110,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6128,7 +6165,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6137,7 +6174,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6448,7 +6485,7 @@ dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "zeroize", ] @@ -6842,6 +6879,7 @@ dependencies = [ "hashbrown 0.15.5", "indexmap 2.14.0", "semver", + "serde", ] [[package]] @@ -7340,9 +7378,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -7517,9 +7555,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/rs/src/build.rs b/rs/src/build.rs new file mode 100644 index 000000000..3d45d6ab3 --- /dev/null +++ b/rs/src/build.rs @@ -0,0 +1,695 @@ +//! Build-script helpers for Sails programs. + +pub use gwasm_builder::build as build_wasm; + +use gwasm_builder::{PreProcessor, PreProcessorTarget, WasmBuilder}; +use sails_storage::{StaticLayout, StaticOpenAddressTable}; +use std::{ + boxed::Box, + collections::BTreeSet, + env, eprintln, + fmt::Write as _, + fs, + path::{Path, PathBuf}, + process, + string::{String, ToString}, + vec, + vec::Vec, +}; +use wasm_encoder::reencode::{self, Reencode, RoundtripReencoder}; + +const GENERATED_STATIC_STORAGE: &str = "sails_static_storage.rs"; +const WASM_PAGE_SIZE: usize = 64 * 1024; + +/// Maximum static WASM pages accepted by Gear. +pub const MAX_STATIC_MEMORY_PAGES: u32 = 32_768; + +/// Static memory layout reserved by a Sails build script. +#[derive(Clone, Debug)] +pub struct StaticMemoryLayout { + start_page: u32, + tables: Vec, +} + +#[derive(Clone, Debug)] +struct StaticTable { + name: String, + slots: usize, + slot_size: usize, + align: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedStaticMemoryLayout { + start_page: u32, + end_page: u32, + tables: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedStaticTable { + name: String, + base: usize, + slots: usize, + bytes: usize, +} + +/// Errors returned by static-memory build helpers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("static memory table name `{0}` is not a valid snake_case Rust identifier")] + InvalidTableName(String), + #[error("static memory table name `{0}` is reserved by Rust")] + ReservedTableName(String), + #[error("static memory table name `{0}` is duplicated")] + DuplicateTableName(String), + #[error("static memory layout overflows")] + LayoutOverflow, + #[error("static memory layout exceeds Gear limit of {MAX_STATIC_MEMORY_PAGES} WASM pages")] + StaticMemoryLimitExceeded, + #[error("static memory layout is invalid: {0}")] + StorageLayout(sails_storage::TableError), + #[error("OUT_DIR is not set for static memory source generation")] + MissingOutDir(#[from] env::VarError), + #[error("static memory build I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("WASM parse failed while patching static memory: {0}")] + WasmParse(#[from] wasmparser::BinaryReaderError), + #[error("WASM import cannot be round-tripped while patching static memory: {0}")] + UnsupportedImport(String), + #[error("WASM payload cannot be round-tripped while patching static memory")] + UnsupportedWasmPayload, + #[error("WASM module has no imported memory")] + MemoryImportNotFound, + #[error("WASM module has multiple imported memories")] + MultipleMemoryImports, + #[error("WASM memory uses custom page size {0:?}; Sails static memory requires 64 KiB pages")] + UnsupportedMemoryPageSize(Option), + #[error( + "static memory start page {start_page} overlaps the original imported static pages {original_pages}" + )] + StaticMemoryOverlapsProgram { + start_page: u32, + original_pages: u64, + }, + #[error( + "static memory requires {required_pages} pages, exceeding imported memory maximum {maximum_pages}" + )] + StaticMemoryExceedsMemoryMaximum { + required_pages: u64, + maximum_pages: u64, + }, +} + +/// Builds a WASM binary after reserving static memory pages. +/// +/// This function exits the build script on error, matching +/// [`gwasm_builder::build`]. +pub fn build_wasm_with_static_memory(layout: StaticMemoryLayout) -> Option<(PathBuf, PathBuf)> { + match try_build_wasm_with_static_memory(layout) { + Ok(paths) => paths, + Err(error) => { + eprintln!("error: {error}"); + process::exit(1); + } + } +} + +/// Fallible variant of [`build_wasm_with_static_memory`]. +pub fn try_build_wasm_with_static_memory( + layout: StaticMemoryLayout, +) -> Result, Error> { + let layout = layout.resolve()?; + emit_static_storage(&layout)?; + + Ok(WasmBuilder::new() + .exclude_features(vec!["std"]) + .with_pre_processor(Box::new(StaticMemoryPreProcessor { layout })) + .build()) +} + +impl StaticMemoryLayout { + /// Creates a layout that starts at `start_page`. + pub fn new(start_page: u32) -> Self { + Self { + start_page, + tables: Vec::new(), + } + } + + /// Reserves a named static open-addressed table. + pub fn reserve_table( + mut self, + name: impl Into, + slots: usize, + ) -> Self { + self.tables.push(StaticTable { + name: name.into(), + slots, + slot_size: StaticOpenAddressTable::::slot_size(), + align: 1, + }); + self + } + + fn resolve(self) -> Result { + let start_base = page_offset(self.start_page)?; + let max_bytes = page_offset(MAX_STATIC_MEMORY_PAGES)?; + let bytes = max_bytes + .checked_sub(start_base) + .ok_or(Error::StaticMemoryLimitExceeded)?; + let mut layout = StaticLayout::new(start_base, bytes).map_err(Error::StorageLayout)?; + let mut names = BTreeSet::new(); + let mut tables = Vec::with_capacity(self.tables.len()); + + for table in self.tables { + validate_table_name(&table.name)?; + if !names.insert(table.name.clone()) { + return Err(Error::DuplicateTableName(table.name)); + } + + let bytes = table + .slots + .checked_mul(table.slot_size) + .ok_or(Error::LayoutOverflow)?; + let region = layout + .reserve_aligned_bytes(bytes, table.align) + .map_err(Error::StorageLayout)?; + let resolved = ResolvedStaticTable { + name: table.name, + base: region.base(), + slots: table.slots, + bytes: region.bytes(), + }; + tables.push(resolved); + } + + let end_page = byte_end_to_page(layout.cursor())?; + Ok(ResolvedStaticMemoryLayout { + start_page: self.start_page, + end_page, + tables, + }) + } +} + +struct StaticMemoryPreProcessor { + layout: ResolvedStaticMemoryLayout, +} + +impl PreProcessor for StaticMemoryPreProcessor { + fn name(&self) -> &'static str { + "sails_static_memory" + } + + fn pre_process( + &self, + original: PathBuf, + ) -> gwasm_builder::PreProcessorResult)>> { + let wasm = fs::read(original)?; + let patched = patch_imported_memory(&wasm, self.layout.start_page, self.layout.end_page)?; + Ok(vec![(PreProcessorTarget::Default, patched)]) + } +} + +fn emit_static_storage(layout: &ResolvedStaticMemoryLayout) -> Result<(), Error> { + let out_dir = env::var("OUT_DIR")?; + emit_static_storage_to_dir(layout, out_dir) +} + +fn emit_static_storage_to_dir( + layout: &ResolvedStaticMemoryLayout, + out_dir: impl AsRef, +) -> Result<(), Error> { + let mut source = String::new(); + writeln!( + &mut source, + "// @generated by sails_rs::build::build_wasm_with_static_memory" + ) + .expect("writing to String cannot fail"); + writeln!( + &mut source, + "#[allow(dead_code)]\npub const STATIC_MEMORY_START_PAGE: u32 = {};", + layout.start_page + ) + .expect("writing to String cannot fail"); + writeln!( + &mut source, + "#[allow(dead_code)]\npub const STATIC_MEMORY_END_PAGE: u32 = {};", + layout.end_page + ) + .expect("writing to String cannot fail"); + + for table in &layout.tables { + let name = table.name.to_ascii_uppercase(); + writeln!(&mut source).expect("writing to String cannot fail"); + writeln!( + &mut source, + "#[allow(dead_code)]\npub const {name}_BASE: usize = {};", + table.base + ) + .expect("writing to String cannot fail"); + writeln!( + &mut source, + "#[allow(dead_code)]\npub const {name}_SLOTS: usize = {};", + table.slots + ) + .expect("writing to String cannot fail"); + writeln!( + &mut source, + "#[allow(dead_code)]\npub const {name}_BYTES: usize = {};", + table.bytes + ) + .expect("writing to String cannot fail"); + } + + fs::write(out_dir.as_ref().join(GENERATED_STATIC_STORAGE), source)?; + Ok(()) +} + +fn patch_imported_memory( + wasm: &[u8], + start_page: u32, + required_pages: u32, +) -> Result, Error> { + let parser = wasmparser::Parser::new(0); + let mut module = wasm_encoder::Module::new(); + let mut reencoder = StaticMemoryReencoder { + memory_import_seen: false, + start_page, + required_pages, + }; + + reencoder + .parse_core_module(&mut module, parser, wasm) + .map_err(map_reencode_error)?; + + if !reencoder.memory_import_seen { + return Err(Error::MemoryImportNotFound); + } + + Ok(module.finish()) +} + +fn map_reencode_error(error: reencode::Error) -> Error { + match error { + reencode::Error::ParseError(error) => Error::WasmParse(error), + reencode::Error::UserError(error) => error, + error => Error::UnsupportedImport(error.to_string()), + } +} + +struct StaticMemoryReencoder { + memory_import_seen: bool, + start_page: u32, + required_pages: u32, +} + +impl Reencode for StaticMemoryReencoder { + type Error = Error; + + fn parse_imports( + &mut self, + imports: &mut wasm_encoder::ImportSection, + import_group: wasmparser::Imports<'_>, + ) -> Result<(), reencode::Error> { + match import_group { + wasmparser::Imports::Single(_, import) => { + self.append_import(imports, import.module, import.name, import.ty)?; + } + wasmparser::Imports::Compact1 { module, items } => { + for item in items { + let item = item?; + self.append_import(imports, module, item.name, item.ty)?; + } + } + wasmparser::Imports::Compact2 { module, ty, names } => { + for name in names { + self.append_import(imports, module, name?, ty)?; + } + } + } + Ok(()) + } +} + +impl StaticMemoryReencoder { + fn append_import( + &mut self, + imports: &mut wasm_encoder::ImportSection, + module: &str, + name: &str, + ty: wasmparser::TypeRef, + ) -> Result<(), reencode::Error> { + let entity = match ty { + wasmparser::TypeRef::Memory(memory) => { + if self.memory_import_seen { + return Err(reencode::Error::UserError(Error::MultipleMemoryImports)); + } + self.memory_import_seen = true; + patched_memory_type(memory, self.start_page, self.required_pages) + .map_err(reencode::Error::UserError)? + } + other => RoundtripReencoder.entity_type(other).map_err(|error| { + reencode::Error::UserError(Error::UnsupportedImport(error.to_string())) + })?, + }; + + imports.import(module, name, entity); + Ok(()) + } +} + +fn patched_memory_type( + memory: wasmparser::MemoryType, + start_page: u32, + required_pages: u32, +) -> Result { + if memory.page_size_log2.is_some_and(|page| page != 16) { + return Err(Error::UnsupportedMemoryPageSize(memory.page_size_log2)); + } + + if u64::from(start_page) < memory.initial { + return Err(Error::StaticMemoryOverlapsProgram { + start_page, + original_pages: memory.initial, + }); + } + + let minimum = u64::from(required_pages).max(memory.initial); + if let Some(maximum) = memory.maximum + && minimum > maximum + { + return Err(Error::StaticMemoryExceedsMemoryMaximum { + required_pages: minimum, + maximum_pages: maximum, + }); + } + + Ok(wasm_encoder::EntityType::Memory(wasm_encoder::MemoryType { + minimum, + maximum: memory.maximum, + memory64: memory.memory64, + shared: memory.shared, + page_size_log2: memory.page_size_log2, + })) +} + +fn page_offset(page: u32) -> Result { + (page as usize) + .checked_mul(WASM_PAGE_SIZE) + .ok_or(Error::LayoutOverflow) +} + +fn byte_end_to_page(byte_end: usize) -> Result { + let pages = byte_end.div_ceil(WASM_PAGE_SIZE); + if pages > MAX_STATIC_MEMORY_PAGES as usize { + return Err(Error::StaticMemoryLimitExceeded); + } + u32::try_from(pages).map_err(|_| Error::StaticMemoryLimitExceeded) +} + +fn validate_table_name(name: &str) -> Result<(), Error> { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return Err(Error::InvalidTableName(name.to_string())); + }; + + if !(first == '_' || first.is_ascii_lowercase()) { + return Err(Error::InvalidTableName(name.to_string())); + } + + if !chars.all(|ch| ch == '_' || ch.is_ascii_lowercase() || ch.is_ascii_digit()) { + return Err(Error::InvalidTableName(name.to_string())); + } + + if is_rust_keyword(name) { + return Err(Error::ReservedTableName(name.to_string())); + } + + Ok(()) +} + +fn is_rust_keyword(name: &str) -> bool { + matches!( + name, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "gen" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "union" + | "unsafe" + | "use" + | "where" + | "while" + | "abstract" + | "become" + | "box" + | "do" + | "final" + | "macro" + | "override" + | "priv" + | "try" + | "typeof" + | "unsized" + | "virtual" + | "yield" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use wasm_encoder::{EntityType, ImportSection, MemoryType, Module}; + + fn module_with_memory(minimum: u64, maximum: Option) -> Vec { + let mut imports = ImportSection::new(); + imports.import( + "env", + "memory", + EntityType::Memory(MemoryType { + minimum, + maximum, + memory64: false, + shared: false, + page_size_log2: None, + }), + ); + + let mut module = Module::new(); + module.section(&imports); + module.finish() + } + + fn memory_minimum(wasm: &[u8]) -> u64 { + for payload in wasmparser::Parser::new(0).parse_all(wasm) { + let wasmparser::Payload::ImportSection(imports) = payload.unwrap() else { + continue; + }; + for imports in imports { + match imports.unwrap() { + wasmparser::Imports::Single(_, import) => { + if let wasmparser::TypeRef::Memory(memory) = import.ty { + return memory.initial; + } + } + wasmparser::Imports::Compact1 { items, .. } => { + for item in items { + let item = item.unwrap(); + if let wasmparser::TypeRef::Memory(memory) = item.ty { + return memory.initial; + } + } + } + wasmparser::Imports::Compact2 { ty, .. } => { + if let wasmparser::TypeRef::Memory(memory) = ty { + return memory.initial; + } + } + } + } + } + + panic!("memory import not found") + } + + #[test] + fn resolves_layout_and_generates_constants() { + let layout = StaticMemoryLayout::new(1024) + .reserve_table::<32, 32>("balances", 2) + .reserve_table::<64, 32>("allowances", 1) + .resolve() + .unwrap(); + + assert_eq!(layout.start_page, 1024); + assert_eq!(layout.end_page, 1025); + assert_eq!(layout.tables[0].base, 1024 * WASM_PAGE_SIZE); + assert_eq!(layout.tables[0].bytes, 2 * (1 + 32 + 32)); + assert_eq!(layout.tables[1].bytes, 1 + 64 + 32); + + let dir = tempfile::tempdir().unwrap(); + emit_static_storage_to_dir(&layout, dir.path()).unwrap(); + let generated = fs::read_to_string(dir.path().join(GENERATED_STATIC_STORAGE)).unwrap(); + assert!(generated.contains("pub const BALANCES_BASE: usize = 67108864;")); + assert!(generated.contains("pub const ALLOWANCES_SLOTS: usize = 1;")); + } + + #[test] + fn resolves_multiple_static_tables_with_metadata() { + let layout = StaticMemoryLayout::new(1024) + .reserve_table::<1, 1>("prefix", 1) + .reserve_table::<32, 32>("fast_balances", 4) + .reserve_table::<64, 32>("fast_allowances", 2) + .resolve() + .unwrap(); + + assert_eq!(layout.tables[1].slots, 4); + assert_eq!( + layout.tables[1].bytes, + 4 * StaticOpenAddressTable::<32, 32>::slot_size() + ); + assert_eq!(layout.tables[2].slots, 2); + assert_eq!( + layout.tables[2].bytes, + 2 * StaticOpenAddressTable::<64, 32>::slot_size() + ); + + let dir = tempfile::tempdir().unwrap(); + emit_static_storage_to_dir(&layout, dir.path()).unwrap(); + let generated = fs::read_to_string(dir.path().join(GENERATED_STATIC_STORAGE)).unwrap(); + assert!(generated.contains("pub const FAST_BALANCES_SLOTS: usize = 4;")); + assert!(generated.contains("pub const FAST_BALANCES_BYTES: usize = 260;")); + assert!(generated.contains("pub const FAST_ALLOWANCES_SLOTS: usize = 2;")); + assert!(generated.contains("pub const FAST_ALLOWANCES_BYTES: usize = 194;")); + } + + #[test] + fn rejects_invalid_duplicate_and_reserved_names() { + assert!(matches!( + StaticMemoryLayout::new(1) + .reserve_table::<1, 1>("bad-name", 1) + .resolve(), + Err(Error::InvalidTableName(_)) + )); + assert!(matches!( + StaticMemoryLayout::new(1) + .reserve_table::<1, 1>("balances", 1) + .reserve_table::<1, 1>("balances", 1) + .resolve(), + Err(Error::DuplicateTableName(_)) + )); + assert!(matches!( + StaticMemoryLayout::new(1) + .reserve_table::<1, 1>("type", 1) + .resolve(), + Err(Error::ReservedTableName(_)) + )); + for name in ["async", "dyn", "gen", "try", "union", "yield"] { + assert!(matches!( + StaticMemoryLayout::new(1) + .reserve_table::<1, 1>(name, 1) + .resolve(), + Err(Error::ReservedTableName(_)) + )); + } + } + + #[test] + fn rejects_layout_overflow_and_page_limit() { + assert!(matches!( + StaticMemoryLayout::new(MAX_STATIC_MEMORY_PAGES) + .reserve_table::<1, 1>("balances", 1) + .resolve(), + Err(Error::StorageLayout(_)) + )); + assert!(matches!( + StaticMemoryLayout::new(1) + .reserve_table::<1, 1>("balances", usize::MAX) + .resolve(), + Err(Error::LayoutOverflow) + )); + } + + #[test] + fn patches_memory_import_minimum() { + let wasm = module_with_memory(1, None); + let patched = patch_imported_memory(&wasm, 8, 10).unwrap(); + + assert_eq!(memory_minimum(&patched), 10); + } + + #[test] + fn preserves_higher_existing_memory_minimum() { + let wasm = module_with_memory(12, None); + let patched = patch_imported_memory(&wasm, 12, 10).unwrap(); + + assert_eq!(memory_minimum(&patched), 12); + } + + #[test] + fn rejects_overlap_with_original_static_pages() { + let wasm = module_with_memory(12, None); + let error = patch_imported_memory(&wasm, 8, 20).unwrap_err(); + + assert!(matches!( + error, + Error::StaticMemoryOverlapsProgram { + start_page: 8, + original_pages: 12, + } + )); + } + + #[test] + fn rejects_memory_maximum_below_required_pages() { + let wasm = module_with_memory(1, Some(4)); + let error = patch_imported_memory(&wasm, 2, 8).unwrap_err(); + + assert!(matches!( + error, + Error::StaticMemoryExceedsMemoryMaximum { + required_pages: 8, + maximum_pages: 4, + } + )); + } + + #[test] + fn rejects_missing_memory_import() { + let wasm = Module::new().finish(); + let error = patch_imported_memory(&wasm, 1, 2).unwrap_err(); + + assert!(matches!(error, Error::MemoryImportNotFound)); + } +} diff --git a/rs/src/lib.rs b/rs/src/lib.rs index 0311573fc..8631bf4b6 100644 --- a/rs/src/lib.rs +++ b/rs/src/lib.rs @@ -1,13 +1,16 @@ #![cfg_attr(not(doctest), doc = include_str!("../README.md"))] #![no_std] -#[cfg(all(feature = "std", not(target_arch = "wasm32")))] +#[cfg(any( + all(feature = "std", not(target_arch = "wasm32")), + all(feature = "wasm-builder", not(target_arch = "wasm32")) +))] extern crate std; +#[cfg(all(feature = "wasm-builder", not(target_arch = "wasm32")))] +pub use build::build_wasm; #[cfg(feature = "client-builder")] pub use builder::{ClientBuilder, ClientGenerator, IdlPath, build_client, build_client_as_lib}; -#[cfg(feature = "wasm-builder")] -pub use gwasm_builder::build as build_wasm; pub use hex; #[doc(hidden)] pub use paste; @@ -21,6 +24,8 @@ pub use sails_idl_gen::generate_idl_to_file; pub use sails_idl_meta::{self as meta}; pub use spin; +#[cfg(all(feature = "wasm-builder", not(target_arch = "wasm32")))] +pub mod build; #[cfg(feature = "client-builder")] mod builder; #[cfg(any( diff --git a/rs/storage/Cargo.toml b/rs/storage/Cargo.toml new file mode 100644 index 000000000..8406e0234 --- /dev/null +++ b/rs/storage/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "sails-storage" +description = "Allocator-light storage primitives for Sails programs" +documentation = "https://docs.rs/sails-storage" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + diff --git a/rs/storage/src/lib.rs b/rs/storage/src/lib.rs new file mode 100644 index 000000000..c3aa0d558 --- /dev/null +++ b/rs/storage/src/lib.rs @@ -0,0 +1,805 @@ +//! Allocator-light storage primitives for Sails programs. +//! +//! This crate provides bounded maps for program state that should not grow +//! through the allocator at runtime. It has two layers: +//! +//! - [`FixedOpenAddressMap`] stores a fixed-capacity map directly inside a +//! Rust value. It is useful for small bounded state and tests. +//! - [`StaticOpenAddressTable`] operates on a caller-reserved memory region. +//! It is useful when a Sails program wants stable, lazy-page-friendly storage +//! outside allocator-managed collections. +//! +//! The static tables are intentionally low level. Constructors are `unsafe` +//! because the caller must reserve a valid writable memory range and keep it +//! from overlapping other mutable state for the whole lifetime of the table. +//! In normal Sails builds, use `sails_rs::build::StaticMemoryLayout` from a +//! build script to reserve static memory and generate base/size constants. +//! +//! # Fixed map +//! +//! ``` +//! use sails_storage::FixedOpenAddressMap; +//! +//! let mut map = FixedOpenAddressMap::<1, 1, 2>::new(); +//! assert_eq!(map.insert([1], [10]), Ok(None)); +//! assert_eq!(map.get(&[1]), Ok(Some([10]))); +//! ``` +//! +//! # Generic static table +//! +//! ``` +//! use sails_storage::StaticOpenAddressTable; +//! +//! let mut memory = [0u8; StaticOpenAddressTable::<1, 1>::slot_size() * 2]; +//! let table = unsafe { +//! StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 2).unwrap() +//! }; +//! +//! assert_eq!(table.insert(&[1], &[10]), Ok(None)); +//! assert_eq!(table.get(&[1]), Ok(Some([10]))); +//! ``` +//! + +#![no_std] + +use core::{fmt, marker::PhantomData, ptr}; + +/// Errors returned by fixed and static storage tables. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableError { + /// The table has no reusable slot for a new key. + CapacityOverflow, + /// The provided memory layout overflows or does not fit the requested region. + InvalidLayout, + /// A static-memory slot state byte is not one of the supported values. + InvalidSlotState, +} + +impl fmt::Display for TableError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CapacityOverflow => f.write_str("capacity overflow"), + Self::InvalidLayout => f.write_str("invalid storage layout"), + Self::InvalidSlotState => f.write_str("invalid slot state"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +enum SlotState { + Empty = 0, + Full = 1, + Deleted = 2, +} + +impl SlotState { + fn from_byte(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Empty), + 1 => Ok(Self::Full), + 2 => Ok(Self::Deleted), + _ => Err(TableError::InvalidSlotState), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SlotMatch { + Found, + Occupied, + Deleted, + Empty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Lookup { + Found(usize), + Vacant(usize), + Full, +} + +fn find_slot( + slots: usize, + hash: u32, + mut classify: impl FnMut(usize) -> Result, +) -> Result { + if slots == 0 { + return Ok(Lookup::Full); + } + + let mut first_deleted = None; + let mut index = start_index(slots, hash); + + for _ in 0..slots { + match classify(index)? { + SlotMatch::Found => return Ok(Lookup::Found(index)), + SlotMatch::Occupied => {} + SlotMatch::Deleted => { + if first_deleted.is_none() { + first_deleted = Some(index); + } + } + SlotMatch::Empty => return Ok(Lookup::Vacant(first_deleted.unwrap_or(index))), + } + + index += 1; + if index == slots { + index = 0; + } + } + + Ok(first_deleted.map_or(Lookup::Full, Lookup::Vacant)) +} + +fn start_index(slots: usize, hash: u32) -> usize { + let hash = hash as usize; + if slots.is_power_of_two() { + hash & (slots - 1) + } else { + hash % slots + } +} + +fn hash_bytes(bytes: &[u8]) -> u32 { + let mut hash = 0x811c_9dc5u32; + + for byte in bytes { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(0x0100_0193); + } + + hash +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FixedSlot { + state: SlotState, + key: [u8; KEY_SIZE], + value: [u8; VALUE_SIZE], +} + +impl FixedSlot { + const EMPTY: Self = Self { + state: SlotState::Empty, + key: [0; KEY_SIZE], + value: [0; VALUE_SIZE], + }; +} + +/// A fixed-capacity open-addressed map stored inside the program state. +/// +/// This type is useful for tests, benchmarks, and small bounded state. For +/// lazy-page optimized storage, use [`StaticOpenAddressTable`] over a static +/// memory region. +pub struct FixedOpenAddressMap { + slots: [FixedSlot; CAP], + len: usize, +} + +impl + FixedOpenAddressMap +{ + /// Creates an empty fixed-capacity map. + pub const fn new() -> Self { + Self { + slots: [FixedSlot::EMPTY; CAP], + len: 0, + } + } + + /// Returns the number of visible entries. + pub const fn len(&self) -> usize { + self.len + } + + /// Returns `true` when the map contains no visible entries. + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns the configured capacity. + pub const fn capacity(&self) -> usize { + CAP + } + + /// Returns the visible value for `key`. + pub fn get(&self, key: &[u8; KEY_SIZE]) -> Result, TableError> { + match self.lookup(key)? { + Lookup::Found(index) => Ok(Some(self.slots[index].value)), + Lookup::Vacant(_) | Lookup::Full => Ok(None), + } + } + + /// Returns all visible key/value pairs in slot order. + pub fn entries(&self) -> impl Iterator + '_ { + self.slots + .iter() + .filter_map(|slot| (slot.state == SlotState::Full).then_some((slot.key, slot.value))) + } + + /// Inserts or updates `key`, returning the previous visible value. + pub fn insert( + &mut self, + key: [u8; KEY_SIZE], + value: [u8; VALUE_SIZE], + ) -> Result, TableError> { + match self.lookup(&key)? { + Lookup::Found(index) => { + let previous = self.slots[index].value; + self.slots[index].value = value; + Ok(Some(previous)) + } + Lookup::Vacant(index) => { + self.slots[index] = FixedSlot { + state: SlotState::Full, + key, + value, + }; + self.len += 1; + Ok(None) + } + Lookup::Full => Err(TableError::CapacityOverflow), + } + } + + /// Removes the visible value for `key`, preserving the probe chain. + pub fn remove(&mut self, key: &[u8; KEY_SIZE]) -> Result, TableError> { + let Lookup::Found(index) = self.lookup(key)? else { + return Ok(None); + }; + + let previous = self.slots[index].value; + self.slots[index].state = SlotState::Deleted; + self.len -= 1; + Ok(Some(previous)) + } + + fn lookup(&self, key: &[u8; KEY_SIZE]) -> Result { + find_slot(CAP, hash_bytes(key), |index| { + let slot = &self.slots[index]; + Ok(match slot.state { + SlotState::Full if slot.key == *key => SlotMatch::Found, + SlotState::Full => SlotMatch::Occupied, + SlotState::Deleted => SlotMatch::Deleted, + SlotState::Empty => SlotMatch::Empty, + }) + }) + } +} + +impl Default + for FixedOpenAddressMap +{ + fn default() -> Self { + Self::new() + } +} + +/// A byte interval reserved for static storage. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StaticRegion { + base: usize, + bytes: usize, +} + +impl StaticRegion { + /// Creates a byte region and validates `base + bytes`. + pub fn new(base: usize, bytes: usize) -> Result { + base.checked_add(bytes).ok_or(TableError::InvalidLayout)?; + Ok(Self { base, bytes }) + } + + /// Returns the region base address. + pub const fn base(self) -> usize { + self.base + } + + /// Returns the region byte length. + pub const fn bytes(self) -> usize { + self.bytes + } + + /// Returns the first byte after this region. + pub fn end(self) -> Result { + self.base + .checked_add(self.bytes) + .ok_or(TableError::InvalidLayout) + } +} + +/// A typed static-memory region for an open-addressed table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TableRegion { + region: StaticRegion, + slots: usize, +} + +impl TableRegion { + /// Returns the byte region backing the table. + pub const fn region(self) -> StaticRegion { + self.region + } + + /// Returns the table base address. + pub const fn base(self) -> usize { + self.region.base() + } + + /// Returns the configured slot count. + pub const fn slots(self) -> usize { + self.slots + } +} + +/// Sequential static-memory layout builder. +pub struct StaticLayout { + cursor: usize, + end: usize, +} + +impl StaticLayout { + /// Creates a layout over `[base, base + bytes)`. + pub fn new(base: usize, bytes: usize) -> Result { + let end = base.checked_add(bytes).ok_or(TableError::InvalidLayout)?; + Ok(Self { cursor: base, end }) + } + + /// Returns the next free address. + pub const fn cursor(&self) -> usize { + self.cursor + } + + /// Returns the first byte after the layout region. + pub const fn end(&self) -> usize { + self.end + } + + /// Returns the number of bytes not yet reserved. + pub fn remaining(&self) -> usize { + self.end - self.cursor + } + + /// Reserves a raw byte region. + pub fn reserve_bytes(&mut self, bytes: usize) -> Result { + self.reserve_aligned_bytes(bytes, 1) + } + + /// Reserves a raw byte region with an aligned base address. + pub fn reserve_aligned_bytes( + &mut self, + bytes: usize, + align: usize, + ) -> Result { + if align == 0 || !align.is_power_of_two() { + return Err(TableError::InvalidLayout); + } + + let misalignment = self.cursor & (align - 1); + let padding = if misalignment == 0 { + 0 + } else { + align - misalignment + }; + let base = self + .cursor + .checked_add(padding) + .ok_or(TableError::InvalidLayout)?; + let next = base.checked_add(bytes).ok_or(TableError::InvalidLayout)?; + + if next > self.end { + return Err(TableError::InvalidLayout); + } + + let region = StaticRegion { base, bytes }; + self.cursor = next; + Ok(region) + } + + /// Reserves a typed table region. + pub fn reserve_table( + &mut self, + slots: usize, + ) -> Result, TableError> { + let bytes = StaticOpenAddressTable::::bytes_len(slots)?; + let region = self.reserve_bytes(bytes)?; + Ok(TableRegion { region, slots }) + } +} + +/// A fixed open-addressed table backed by caller-owned static memory. +pub struct StaticOpenAddressTable { + base: usize, + slots: usize, + _marker: PhantomData<*mut u8>, +} + +impl StaticOpenAddressTable { + /// Returns the byte length of one slot. + pub const fn slot_size() -> usize { + 1 + KEY_SIZE + VALUE_SIZE + } + + /// Returns the byte length required for `slots`. + pub fn bytes_len(slots: usize) -> Result { + slots + .checked_mul(Self::slot_size()) + .ok_or(TableError::InvalidLayout) + } + + /// Creates a table over `slots * slot_size()` bytes at `base`. + /// + /// # Safety + /// + /// The caller must ensure the memory interval is valid for reads and writes + /// for the whole lifetime of the table and does not overlap other mutable + /// state. + pub unsafe fn new(base: usize, slots: usize) -> Result { + let bytes = Self::bytes_len(slots)?; + StaticRegion::new(base, bytes)?; + Ok(Self { + base, + slots, + _marker: PhantomData, + }) + } + + /// Creates a table from a typed static-memory region. + /// + /// # Safety + /// + /// The caller must ensure the memory interval is valid for reads and writes + /// for the whole lifetime of the table and does not overlap other mutable + /// state. + pub unsafe fn from_region( + region: TableRegion, + ) -> Result { + let expected = Self::bytes_len(region.slots)?; + if expected > region.region.bytes { + return Err(TableError::InvalidLayout); + } + + unsafe { Self::new(region.region.base, region.slots) } + } + + /// Returns the configured base address. + pub const fn base(&self) -> usize { + self.base + } + + /// Returns the configured slot count. + pub const fn slots(&self) -> usize { + self.slots + } + + /// Returns the total byte length occupied by this table. + pub fn bytes(&self) -> Result { + Self::bytes_len(self.slots) + } + + /// Returns the visible value for `key`. + pub fn get(&self, key: &[u8; KEY_SIZE]) -> Result, TableError> { + match self.lookup(key)? { + Lookup::Found(index) => unsafe { self.read_value(index).map(Some) }, + Lookup::Vacant(_) | Lookup::Full => Ok(None), + } + } + + /// Inserts or updates `key`, returning the previous visible value. + pub fn insert( + &self, + key: &[u8; KEY_SIZE], + value: &[u8; VALUE_SIZE], + ) -> Result, TableError> { + match self.lookup(key)? { + Lookup::Found(index) => { + let previous = unsafe { self.read_value(index)? }; + unsafe { + self.write_value(index, value); + } + Ok(Some(previous)) + } + Lookup::Vacant(index) => { + unsafe { + self.write_key(index, key); + self.write_value(index, value); + self.write_state(index, SlotState::Full); + } + Ok(None) + } + Lookup::Full => Err(TableError::CapacityOverflow), + } + } + + /// Removes the visible value for `key`, preserving the probe chain. + pub fn remove(&self, key: &[u8; KEY_SIZE]) -> Result, TableError> { + let Lookup::Found(index) = self.lookup(key)? else { + return Ok(None); + }; + + let previous = unsafe { self.read_value(index)? }; + unsafe { + self.write_state(index, SlotState::Deleted); + } + Ok(Some(previous)) + } + + /// Clears every slot to the empty state. + pub fn clear(&self) -> Result<(), TableError> { + let bytes = self.bytes()?; + unsafe { + ptr::write_bytes(self.base as *mut u8, 0, bytes); + } + Ok(()) + } + + fn lookup(&self, key: &[u8; KEY_SIZE]) -> Result { + find_slot(self.slots, hash_bytes(key), |index| { + let state = unsafe { self.read_state(index)? }; + Ok(match state { + SlotState::Full if unsafe { self.key_matches(index, key) } => SlotMatch::Found, + SlotState::Full => SlotMatch::Occupied, + SlotState::Deleted => SlotMatch::Deleted, + SlotState::Empty => SlotMatch::Empty, + }) + }) + } + + unsafe fn read_state(&self, slot: usize) -> Result { + let byte = unsafe { ptr::read(self.state_ptr(slot)) }; + SlotState::from_byte(byte) + } + + unsafe fn key_matches(&self, slot: usize, key: &[u8; KEY_SIZE]) -> bool { + unsafe { core::slice::from_raw_parts(self.key_ptr(slot), KEY_SIZE) == key.as_slice() } + } + + unsafe fn read_value(&self, slot: usize) -> Result<[u8; VALUE_SIZE], TableError> { + let mut bytes = [0u8; VALUE_SIZE]; + unsafe { + ptr::copy_nonoverlapping(self.value_ptr(slot), bytes.as_mut_ptr(), VALUE_SIZE); + } + Ok(bytes) + } + + unsafe fn write_state(&self, slot: usize, state: SlotState) { + unsafe { + ptr::write(self.state_ptr(slot), state as u8); + } + } + + unsafe fn write_key(&self, slot: usize, key: &[u8; KEY_SIZE]) { + unsafe { + ptr::copy_nonoverlapping(key.as_ptr(), self.key_ptr(slot), KEY_SIZE); + } + } + + unsafe fn write_value(&self, slot: usize, value: &[u8; VALUE_SIZE]) { + unsafe { + ptr::copy_nonoverlapping(value.as_ptr(), self.value_ptr(slot), VALUE_SIZE); + } + } + + fn state_ptr(&self, slot: usize) -> *mut u8 { + self.slot_ptr(slot) + } + + fn key_ptr(&self, slot: usize) -> *mut u8 { + unsafe { self.slot_ptr(slot).add(1) } + } + + fn value_ptr(&self, slot: usize) -> *mut u8 { + unsafe { self.slot_ptr(slot).add(1 + KEY_SIZE) } + } + + fn slot_ptr(&self, slot: usize) -> *mut u8 { + (self.base + slot * Self::slot_size()) as *mut u8 + } +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use std::{vec, vec::Vec}; + + type Fixed = FixedOpenAddressMap<1, 1, 2>; + + fn colliding_keys(slots: usize) -> ([u8; 1], [u8; 1]) { + for left in 0u8..=u8::MAX { + for right in left.wrapping_add(1)..=u8::MAX { + if start_index(slots, hash_bytes(&[left])) + == start_index(slots, hash_bytes(&[right])) + { + return ([left], [right]); + } + } + } + + unreachable!("single-byte key space must contain collisions") + } + + #[test] + fn fixed_map_updates_existing_key() { + let mut map = Fixed::new(); + + assert_eq!(map.insert([0], [0]), Ok(None)); + assert_eq!(map.insert([0], [7]), Ok(Some([0]))); + assert_eq!(map.get(&[0]), Ok(Some([7]))); + assert_eq!(map.len(), 1); + } + + #[test] + fn fixed_map_reuses_tombstone() { + let (first, second) = colliding_keys(1); + let mut map = FixedOpenAddressMap::<1, 1, 1>::new(); + + assert_eq!(map.insert(first, [1]), Ok(None)); + assert_eq!(map.remove(&first), Ok(Some([1]))); + assert_eq!(map.insert(second, [2]), Ok(None)); + assert_eq!(map.get(&second), Ok(Some([2]))); + assert_eq!(map.len(), 1); + } + + #[test] + fn fixed_map_reports_full_table() { + let mut map = FixedOpenAddressMap::<1, 1, 1>::new(); + + assert_eq!(map.insert([1], [1]), Ok(None)); + assert_eq!(map.insert([2], [2]), Err(TableError::CapacityOverflow)); + } + + #[test] + fn fixed_map_stores_zero_key_and_zero_value() { + let mut map = Fixed::new(); + + assert_eq!(map.insert([0], [0]), Ok(None)); + assert_eq!(map.get(&[0]), Ok(Some([0]))); + assert_eq!(map.remove(&[0]), Ok(Some([0]))); + assert_eq!(map.get(&[0]), Ok(None)); + } + + #[test] + fn fixed_map_entries_return_visible_pairs_only() { + let mut map = FixedOpenAddressMap::<1, 1, 4>::new(); + + map.insert([1], [10]).unwrap(); + map.insert([2], [20]).unwrap(); + map.remove(&[1]).unwrap(); + map.insert([3], [30]).unwrap(); + + let mut entries = map.entries().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + + assert_eq!(entries, vec![([2], [20]), ([3], [30])]); + } + + #[test] + fn static_table_updates_existing_key() { + let mut memory = [0u8; 8]; + let table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 2).unwrap() + }; + + assert_eq!(table.insert(&[0], &[0]), Ok(None)); + assert_eq!(table.insert(&[0], &[7]), Ok(Some([0]))); + assert_eq!(table.get(&[0]), Ok(Some([7]))); + } + + #[test] + fn static_table_reuses_tombstone() { + let (first, second) = colliding_keys(1); + let mut memory = [0u8; 3]; + let table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 1).unwrap() + }; + + assert_eq!(table.insert(&first, &[1]), Ok(None)); + assert_eq!(table.remove(&first), Ok(Some([1]))); + assert_eq!(table.insert(&second, &[2]), Ok(None)); + assert_eq!(table.get(&second), Ok(Some([2]))); + } + + #[test] + fn static_table_reports_full_table() { + let mut memory = [0u8; 3]; + let table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 1).unwrap() + }; + + assert_eq!(table.insert(&[1], &[1]), Ok(None)); + assert_eq!(table.insert(&[2], &[2]), Err(TableError::CapacityOverflow)); + } + + #[test] + fn static_table_reports_invalid_slot_state() { + let mut memory = [9u8, 0, 0]; + let table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 1).unwrap() + }; + + assert_eq!(table.get(&[0]), Err(TableError::InvalidSlotState)); + } + + #[test] + fn static_table_clear_resets_deleted_slots() { + let mut memory = [0u8; 3]; + let table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 1).unwrap() + }; + + table.insert(&[1], &[1]).unwrap(); + table.remove(&[1]).unwrap(); + table.clear().unwrap(); + assert_eq!(memory, [0, 0, 0]); + } + + #[test] + fn static_layout_reserves_non_overlapping_regions() { + let mut layout = StaticLayout::new(100, 16).unwrap(); + let table = layout.reserve_table::<1, 1>(2).unwrap(); + let value = layout.reserve_bytes(4).unwrap(); + + assert_eq!(table.base(), 100); + assert_eq!(table.region().bytes(), 6); + assert_eq!(value.base(), 106); + assert_eq!(value.bytes(), 4); + assert_eq!(layout.remaining(), 6); + } + + #[test] + fn static_layout_reserves_aligned_regions() { + let mut layout = StaticLayout::new(100, 32).unwrap(); + let value = layout.reserve_aligned_bytes(4, 16).unwrap(); + + assert_eq!(value.base(), 112); + assert_eq!(value.bytes(), 4); + assert_eq!(layout.cursor(), 116); + assert_eq!( + layout.reserve_aligned_bytes(1, 3).err(), + Some(TableError::InvalidLayout) + ); + } + + #[test] + fn static_layout_rejects_overflow_and_out_of_bounds() { + assert_eq!( + StaticLayout::new(usize::MAX, 1).err(), + Some(TableError::InvalidLayout) + ); + + let mut layout = StaticLayout::new(0, 2).unwrap(); + assert_eq!( + layout.reserve_table::<1, 1>(1).err(), + Some(TableError::InvalidLayout) + ); + } + + #[test] + fn fixed_and_static_tables_match_representative_ops() { + let mut fixed = FixedOpenAddressMap::<1, 1, 4>::new(); + let mut memory = [0u8; 12]; + let static_table = unsafe { + StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 4).unwrap() + }; + + for (key, value) in [([0], [0]), ([1], [2]), ([2], [3])] { + assert_eq!(fixed.insert(key, value), static_table.insert(&key, &value)); + } + + assert_eq!(fixed.remove(&[1]), static_table.remove(&[1])); + assert_eq!(fixed.insert([3], [4]), static_table.insert(&[3], &[4])); + + for key in [[0], [1], [2], [3]] { + assert_eq!(fixed.get(&key), static_table.get(&key)); + } + } + + #[test] + fn hash_bytes_is_arch_stable() { + assert_eq!(hash_bytes(&[]), 0x811c_9dc5); + assert_eq!(hash_bytes(&[0]), 0x050c_5d1f); + assert_eq!(hash_bytes(&[1, 2, 3, 4]), 0x5734_a87d); + } +} diff --git a/rs/storage/tests/public_api.rs b/rs/storage/tests/public_api.rs new file mode 100644 index 000000000..b2c2098e6 --- /dev/null +++ b/rs/storage/tests/public_api.rs @@ -0,0 +1,35 @@ +use sails_storage::{ + FixedOpenAddressMap, StaticLayout, StaticOpenAddressTable, StaticRegion, TableError, +}; + +#[test] +fn root_fixed_and_static_table_api_is_usable_by_consumers() { + let mut fixed = FixedOpenAddressMap::<1, 1, 2>::new(); + assert_eq!(fixed.insert([1], [10]), Ok(None)); + assert_eq!(fixed.get(&[1]), Ok(Some([10]))); + assert_eq!(fixed.remove(&[1]), Ok(Some([10]))); + + let mut memory = vec![0u8; StaticOpenAddressTable::<1, 1>::bytes_len(2).unwrap()]; + let table = + unsafe { StaticOpenAddressTable::<1, 1>::new(memory.as_mut_ptr() as usize, 2).unwrap() }; + assert_eq!(table.insert(&[1], &[10]), Ok(None)); + assert_eq!(table.get(&[1]), Ok(Some([10]))); + + let mut layout = StaticLayout::new(1024, 4096).unwrap(); + let region = layout.reserve_table::<1, 1>(2).unwrap(); + assert_eq!(region.base(), 1024); + assert_eq!(region.slots(), 2); +} + +#[test] +fn root_static_region_api_is_usable_by_consumers() { + let region = StaticRegion::new(10, 4).unwrap(); + + assert_eq!(region.base(), 10); + assert_eq!(region.bytes(), 4); + assert_eq!(region.end(), Ok(14)); + assert_eq!( + StaticRegion::new(usize::MAX, 1), + Err(TableError::InvalidLayout) + ); +}