diff --git a/Cargo.lock b/Cargo.lock index 6af5986..a504e54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -31,9 +37,9 @@ checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -58,7 +64,19 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", +] + +[[package]] +name = "auditable-serde" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d026218ae25ba5c72834245412dd1338f6d270d2c5109ee03a4badec288d4056" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", ] [[package]] @@ -69,9 +87,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -117,9 +135,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -129,9 +147,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chacha20" @@ -276,7 +294,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", "log", "postcard", @@ -338,7 +356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ff7cb014bd08752210eba55a650969f8db9b52670d69d2ea0b23e1e9e3edf" dependencies = [ "cranelift-codegen", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -459,7 +477,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -496,9 +514,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -516,11 +534,27 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filesystem-latch" +version = "0.1.0" +dependencies = [ + "heck", + "wit-bindgen", + "wit-parser 0.259.0", +] + +[[package]] +name = "filesystem-latch-n" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -528,6 +562,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -638,6 +683,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gate" +version = "0.1.0" +dependencies = [ + "chrono", + "componentized-rt", + "heck", + "tempfile", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -683,6 +742,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.16.1" @@ -691,9 +756,9 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[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 = [ "foldhash", "serde", @@ -842,12 +907,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -891,9 +956,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ittapi" @@ -927,15 +992,100 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] +[[package]] +name = "latch-defer-all" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", + "wit-bindgen", +] + +[[package]] +name = "latch-deny-all" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "latch-deny-random" +version = "0.1.0" +dependencies = [ + "filesystem-latch", + "tempfile", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "latch-deny-symlink-at" +version = "0.1.0" +dependencies = [ + "tempfile", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "latch-n2" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n3" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n4" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n5" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-path-glob" +version = "0.1.0" +dependencies = [ + "filesystem-latch", + "glob", + "wit-bindgen", +] + +[[package]] +name = "latch-readonly-tests" +version = "0.1.0" +dependencies = [ + "tempfile", + "test-harness", + "tokio", + "wasmtime", +] + [[package]] name = "leb128" version = "0.2.7" @@ -950,9 +1100,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -1001,7 +1151,7 @@ checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1012,9 +1162,9 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memfd" @@ -1025,6 +1175,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -1049,7 +1209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" dependencies = [ "crc32fast", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "memchr", ] @@ -1116,7 +1276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1203,18 +1363,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "readonly" -version = "0.1.0" -dependencies = [ - "componentized-rt", - "tempfile", - "test-harness", - "tokio", - "wasmtime", - "wit-bindgen", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -1234,7 +1382,7 @@ checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "rustc-hash", "serde", @@ -1278,15 +1426,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.19" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -1306,9 +1448,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1316,34 +1458,35 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -1372,6 +1515,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -1403,6 +1552,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spdx" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646c5a367da914e3babcb9cd8e91c37ccaac42325b8f86f036513ad103f2a716" +dependencies = [ + "smallvec", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1422,9 +1580,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1439,7 +1597,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1475,6 +1633,7 @@ name = "test-harness" version = "0.1.0" dependencies = [ "tokio", + "wac-graph", "wasmtime", "wasmtime-wasi", ] @@ -1516,7 +1675,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1552,7 +1711,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1594,6 +1753,12 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "trace" version = "0.1.0" @@ -1646,9 +1811,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-width" @@ -1690,6 +1855,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wac-graph" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0418130ea1c8f995ac51fd495080942604bca6455e6fb11f110aa1cefd3f549" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "petgraph", + "semver", + "thiserror 1.0.69", + "wac-types", + "wasm-encoder 0.258.3", + "wasm-metadata 0.258.3", + "wasmparser 0.258.3", +] + +[[package]] +name = "wac-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5580726576266490359b1457abbc0393e1780295e48a6892500f2b7e68edc74d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "semver", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1698,9 +1896,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -1711,9 +1909,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1721,22 +1919,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -1795,7 +1993,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" dependencies = [ "anyhow", + "auditable-serde", + "flate2", "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", "wasm-encoder 0.258.3", "wasmparser 0.258.3", ] @@ -1819,7 +2024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "semver", "serde", @@ -1832,7 +2037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" dependencies = [ "bitflags", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "semver", ] @@ -1924,7 +2129,7 @@ dependencies = [ "cranelift-bitset", "cranelift-entity", "gimli", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "log", "object", @@ -1991,7 +2196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba00c5db77adb0749ee059068e5a417bfac3d8969f5a3479d9bd1ca8c1ba248d" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", "serde", ] @@ -2499,7 +2704,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 3.0.5", + "syn 3.0.9", "wasm-metadata 0.259.0", "wit-bindgen-core", "wit-component 0.259.0", @@ -2516,7 +2721,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2566,7 +2771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", @@ -2585,7 +2790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", @@ -2634,7 +2839,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "synstructure", ] @@ -2655,7 +2860,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "synstructure", ] @@ -2689,9 +2894,21 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 2589883..ec3ed3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,18 +2,33 @@ resolver = "2" members = [ "components/chroot", - "components/readonly", + "components/gate", + "components/latch-defer-all", + "components/latch-deny-all", + "components/latch-deny-random", + "components/latch-deny-symlink-at", + "components/latch-n2", + "components/latch-n3", + "components/latch-n4", + "components/latch-n5", + "components/latch-path-glob", + "components/latch-readonly", "components/trace", + "crates/filesystem-latch", + "crates/filesystem-latch-n", "crates/test-harness", ] [workspace.dependencies] chrono = { git = "https://github.com/chronotope/chrono.git", branch = "0.5.x" } componentized-rt = { path = "./crates/componentized-rt" } +filesystem-latch = { path = "./crates/filesystem-latch" } +filesystem-latch-n = { path = "./crates/filesystem-latch-n" } heck = "0.5" tempfile = "3" test-harness = { path = "./crates/test-harness" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } +wac-graph = "0.12" wasmtime = { version = "49", features = ["component-model-async"] } wasmtime-wasi = { version = "49", features = ["p3"] } wit-bindgen = { version = "0.62.0", features = ["async-spawn"] } diff --git a/Makefile b/Makefile index 9e26af7..24bdad4 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,6 @@ clean: clean-components clean-wit clean-all: clean-components clean-tools clean-wit cargo clean - .PHONY: test test: components cargo test --workspace diff --git a/README.md b/README.md index 86f9f8f..bbc4ee2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,16 @@ A collection of utility components that remix wasi:filesystem types and interfac ## Components - [`chroot`](./components/chroot/) -- [`readonly`](./components/readonly/) +- [`gate`](./components/gate/) +- [`latch-deny-all`](./components/latch-deny-all/) +- [`latch-deny-random`](./components/latch-deny-random/) +- [`latch-deny-symlink-at`](./components/latch-deny-symlink-at/) +- [`latch-defer-all`](./components/latch-defer-all/) +- [`latch-n2`](./components/latch-n2/) +- [`latch-n3`](./components/latch-n3/) +- [`latch-n4`](./components/latch-n4/) +- [`latch-n5`](./components/latch-n5/) +- [`latch-readonly`](./components/latch-readonly/) - [`trace`](./components/trace/) ## Build diff --git a/components/readonly/Cargo.toml b/components/gate/Cargo.toml similarity index 83% rename from components/readonly/Cargo.toml rename to components/gate/Cargo.toml index 3e5511f..fee30fe 100644 --- a/components/readonly/Cargo.toml +++ b/components/gate/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "readonly" +name = "gate" version = "0.1.0" edition = "2021" license = "Apache-2.0" @@ -8,7 +8,9 @@ license = "Apache-2.0" crate-type = ["cdylib"] [dependencies] +chrono = { workspace = true } componentized-rt = { workspace = true, features = ["executor"] } +heck = { workspace = true } wit-bindgen = { workspace = true } [dev-dependencies] diff --git a/components/gate/README.md b/components/gate/README.md new file mode 100644 index 0000000..7c7375e --- /dev/null +++ b/components/gate/README.md @@ -0,0 +1,41 @@ +# `gate` + +Filesystem gate access control for preopens and descriptors. + +Each operation is authorized by the imported latch before it reaches the filesystem, then the latch is told the final decision with `observe-decision`. Descriptor operations are authorized with the descriptor, its path, and the operation's arguments. The path of a preopen is its name, e.g. `/`, a descriptor opened with `open-at` has the path it was opened at, with `.` and `..` applied and symlinks followed where the host follows them. Testing whether two descriptors are the same object with `is-same-object` is not gated. + +How a denial is enforced depends on the operation: + +- Most operations fail with the error code the latch denied them with. +- `read-via-stream` and `read-directory` return an empty stream, and the future resolves to the error. +- `write-via-stream` and `append-via-stream` do not read the data stream, and the future resolves to the error. +- Getting the preopens is authorized once, a denial returns no preopens. Each preopen is then authorized individually, a denied preopen is left out and the other preopens are returned. +- Directory entries are authorized individually as they are read. A denied entry is left out of the stream and the read continues with the next entry. + +Denials are logged as warnings, except denied preopens and directory entries, which are left out rather than failed and are logged at the trace level: + +``` +Denied REASON=access OPERATION=wasi:filesystem/types#descriptor.open-at FD=/ PATH-FLAGS=() PATH=file.txt OPEN-FLAGS=(create) FLAGS=(read|write) +Denied REASON=access OPERATION=wasi:filesystem/types#descriptor.read-directory FD=/ NAME=file.txt TYPE=regular-file +``` + +A latch error, or a failure to observe the decision, fails the operation with `other`, prefixed with `latch-error:`, and is logged as an error. A latch error for a directory entry ends the stream and fails the read. Getting the preopens can not fail, a latch error traps instead. + +``` +Latch error CODE=invalid-config OPERATION=wasi:filesystem/types#descriptor.stat FD=/file.txt +``` + +## Interfaces + +Imports: + +- `componentized:filesystem/latch@0.3.0-dev`: decides whether each operation may proceed +- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors +- `wasi:clocks/system-clock@0.3.0`: types used by wasi:filesystem/types +- `wasi:filesystem/types@0.3.0`: the descriptors the gated descriptors wrap +- `wasi:filesystem/preopens@0.3.0`: the preopens the gated preopens wrap + +Exports: + +- `wasi:filesystem/types@0.3.0`: descriptors gated by the latch +- `wasi:filesystem/preopens@0.3.0`: preopens gated by the latch diff --git a/components/readonly/build.rs b/components/gate/build.rs similarity index 100% rename from components/readonly/build.rs rename to components/gate/build.rs diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs new file mode 100644 index 0000000..b57d592 --- /dev/null +++ b/components/gate/src/lib.rs @@ -0,0 +1,1462 @@ +use std::fmt::Display; +use std::rc::Rc; + +use chrono::DateTime; +use heck::ToKebabCase; +use wit_bindgen::{FutureReader, StreamReader, StreamResult}; + +use exports::wasi::filesystem::preopens::Guest as Preopens; +use exports::wasi::filesystem::types::{ + Advice, Descriptor, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, + DirectoryEntry, ErrorCode, Filesize, Guest as Types, MetadataHashValue, NewTimestamp, + OpenFlags, PathFlags, +}; +use wasi::filesystem::preopens; +use wasi::filesystem::types; +use wasi::logging::logging::{log, Level}; + +mod path; + +use path::Location; + +use crate::componentized::filesystem::latch::{ + self, Decision, DescriptorAdviseArgs, DescriptorCreateDirectoryAtArgs, DescriptorLinkAtArgs, + DescriptorMetadataHashAtArgs, DescriptorOpenAtArgs, DescriptorOperation, + DescriptorReadlinkAtArgs, DescriptorRemoveDirectoryAtArgs, DescriptorRenameAtArgs, + DescriptorSetSizeArgs, DescriptorSetTimesArgs, DescriptorSetTimesAtArgs, DescriptorStatAtArgs, + DescriptorSymlinkAtArgs, DescriptorUnlinkFileAtArgs, PreopensOperation, +}; +use crate::componentized::filesystem::latch::{ + Decision::{Deferred, Denied}, + DescriptorReadViaStreamArgs, DescriptorWriteViaStreamArgs, ErrorCode as LatchErrorCode, + Operation, +}; + +macro_rules! error { + ($dst:expr, $($arg:tt)*) => { + log(Level::Error, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Error, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! warn { + ($dst:expr, $($arg:tt)*) => { + log(Level::Warn, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Warn, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! trace { + ($dst:expr, $($arg:tt)*) => { + log(Level::Trace, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Trace, "componentized-gate", &format!($dst)); + }; +} + +/// Authorize the operation with the latch, then report the decision back to the latch. Latches +/// act on the final decision in `observe-decision`, never while authorizing. Failing to observe +/// the decision fails the operation, the same as a latch error. +fn authorize(operation: &Operation) -> Result { + let decision = latch::authorize(operation)?; + latch::observe_decision(&decision, operation)?; + Ok(decision) +} + +struct GatedFilesystem {} + +impl Preopens for GatedFilesystem { + #[doc = "/ Return the set of preopened directories, and their paths."] + #[allow(async_fn_in_trait)] + fn get_directories() -> Vec<(Descriptor, String)> { + let call_summary = || format!("OPERATION=wasi:filesystem/preopens#get-directories"); + match authorize(&Operation::Preopens(PreopensOperation::GetDirectories)) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + vec![] + } + Ok(Deferred) => preopens::get_directories() + .into_iter() + .filter(|(fd, path)| { + match authorize(&Operation::Preopens(PreopensOperation::GetDirectoriesItem( + (fd, path.clone()), + ))) { + Ok(Denied(reason)) => { + trace!( + "Denied REASON={reason} {} PATH={path}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + false + } + _ => true, + } + }) + .map(|(fd, path)| { + ( + Descriptor::new(GatedDescriptor::new(fd, Location::preopen(&path))), + path, + ) + }) + .collect(), + Err(code) => { + panic!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ) + } + } + } +} + +impl Types for GatedFilesystem { + type Descriptor = GatedDescriptor; +} + +struct GatedDescriptor { + /// Shared with background tasks that outlive the export which started them. + fd: Rc, + /// What the descriptor is, the base `PATH` arguments are resolved against. + location: Location, + /// Resolved path contained within the location, pre-computed for efficiency + path: String, +} + +impl GatedDescriptor { + fn new(fd: types::Descriptor, location: Location) -> Self { + let path = location.to_string(); + Self { + fd: Rc::new(fd), + location, + path, + } + } +} + +impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { + #[doc = "/ Return a stream for reading from a file."] + #[doc = "/"] + #[doc = "/ Multiple read, write, and append streams may be active on the same open"] + #[doc = "/ file and they do not interfere with each other."] + #[doc = "/"] + #[doc = "/ This function returns a `stream` which provides the data received from the"] + #[doc = "/ file, and a `future` providing additional error information in case an"] + #[doc = "/ error is encountered."] + #[doc = "/"] + #[doc = "/ If no error is encountered, `stream.read` on the `stream` will return"] + #[doc = "/ `read-status::closed` with no `error-context` and the future resolves to"] + #[doc = "/ the value `ok`. If an error is encountered, `stream.read` on the"] + #[doc = "/ `stream` returns `read-status::closed` with an `error-context` and the future"] + #[doc = "/ resolves to `err` with an `error-code`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `pread` in POSIX."] + #[allow(async_fn_in_trait)] + fn read_via_stream( + &self, + offset: Filesize, + ) -> ( + wit_bindgen::StreamReader, + wit_bindgen::FutureReader>, + ) { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.read-via-stream FD={self} OFFSET={offset}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::ReadViaStream( + ( + &self.fd, + self.path.clone(), + DescriptorReadViaStreamArgs { offset }, + ), + ))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + error_stream(reason) + } + Ok(Deferred) => self.fd.read_via_stream(offset), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + error_stream(code.into()) + } + } + } + + #[doc = "/ Return a stream for writing to a file, if available."] + #[doc = "/"] + #[doc = "/ May fail with an error-code describing why the file cannot be written."] + #[doc = "/"] + #[doc = "/ It is valid to write past the end of a file; the file is extended to the"] + #[doc = "/ extent of the write, with bytes between the previous end and the start of"] + #[doc = "/ the write set to zero."] + #[doc = "/"] + #[doc = "/ This function returns once either full contents of the stream are"] + #[doc = "/ written or an error is encountered."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `pwrite` in POSIX."] + #[allow(async_fn_in_trait)] + fn write_via_stream( + &self, + data: wit_bindgen::StreamReader, + offset: Filesize, + ) -> wit_bindgen::FutureReader> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.write-via-stream FD={self} OFFSET={offset}", + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::WriteViaStream( + ( + &self.fd, + self.path.clone(), + DescriptorWriteViaStreamArgs { offset }, + ), + ))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + error_future(reason) + } + Ok(Deferred) => self.fd.write_via_stream(data, offset), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + error_future(code.into()) + } + } + } + + #[doc = "/ Return a stream for appending to a file, if available."] + #[doc = "/"] + #[doc = "/ May fail with an error-code describing why the file cannot be appended."] + #[doc = "/"] + #[doc = "/ This function returns once either full contents of the stream are"] + #[doc = "/ written or an error is encountered."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `write` with `O_APPEND` in POSIX."] + #[allow(async_fn_in_trait)] + fn append_via_stream( + &self, + data: wit_bindgen::StreamReader, + ) -> wit_bindgen::FutureReader> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.append-via-stream FD={self}"); + match authorize(&Operation::Descriptor( + DescriptorOperation::AppendViaStream((&self.fd, self.path.clone())), + )) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + error_future(reason) + } + Ok(Deferred) => self.fd.append_via_stream(data), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + error_future(code.into()) + } + } + } + + #[doc = "/ Provide file advisory information on a descriptor."] + #[doc = "/"] + #[doc = "/ This is similar to `posix_fadvise` in POSIX."] + #[allow(async_fn_in_trait)] + async fn advise( + &self, + offset: Filesize, + length: Filesize, + advice: Advice, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.advise FD={self} OFFSET={offset} LENGTH={length} ADVICE={advice}", + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::Advise(( + &self.fd, + self.path.clone(), + DescriptorAdviseArgs { + offset, + length, + advice, + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason)? + } + Ok(Deferred) => self.fd.advise(offset, length, advice).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Synchronize the data of a file to disk."] + #[doc = "/"] + #[doc = "/ This function succeeds with no effect if the file descriptor is not"] + #[doc = "/ opened for writing."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fdatasync` in POSIX."] + #[allow(async_fn_in_trait)] + async fn sync_data(&self) -> Result<(), ErrorCode> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.sync-data FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::SyncData(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason)? + } + Ok(Deferred) => self.fd.sync_data().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Get flags associated with a descriptor."] + #[doc = "/"] + #[doc = "/ Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This returns the value that was the `fs_flags` value returned"] + #[doc = "/ from `fdstat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn get_flags(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.get-flags FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::GetFlags(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.get_flags().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Get the dynamic type of a descriptor."] + #[doc = "/"] + #[doc = "/ Note: This returns the same value as the `type` field of the `fd-stat`"] + #[doc = "/ returned by `stat`, `stat-at` and similar."] + #[doc = "/"] + #[doc = "/ Note: This returns similar flags to the `st_mode & S_IFMT` value provided"] + #[doc = "/ by `fstat` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This returns the value that was the `fs_filetype` value returned"] + #[doc = "/ from `fdstat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn get_type(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.get-type FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::GetType(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.get_type().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the size of an open file. If this increases the file\'s size, the"] + #[doc = "/ extra bytes are filled with zeros."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_set_size` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn set_size(&self, size: Filesize) -> Result<(), ErrorCode> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.set-size FD={self} SIZE={size}"); + match authorize(&Operation::Descriptor(DescriptorOperation::SetSize(( + &self.fd, + self.path.clone(), + DescriptorSetSizeArgs { size }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.set_size(size).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the timestamps of an open file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `futimens` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_set_times` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn set_times( + &self, + data_access_timestamp: NewTimestamp, + data_modification_timestamp: NewTimestamp, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.set-times FD={self} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}", + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SetTimes(( + &self.fd, + self.path.clone(), + DescriptorSetTimesArgs { + data_access_timestamp, + data_modification_timestamp, + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => { + self.fd + .set_times(data_access_timestamp, data_modification_timestamp) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Read directory entries from a directory."] + #[doc = "/"] + #[doc = "/ On filesystems where directories contain entries referring to themselves"] + #[doc = "/ and their parents, often named `.` and `..` respectively, these entries"] + #[doc = "/ are omitted."] + #[doc = "/"] + #[doc = "/ This always returns a new stream which starts at the beginning of the"] + #[doc = "/ directory. Multiple streams may be active on the same directory, and they"] + #[doc = "/ do not interfere with each other."] + #[doc = "/"] + #[doc = "/ This function returns a future, which will resolve to an error code if"] + #[doc = "/ reading full contents of the directory fails."] + #[allow(async_fn_in_trait)] + fn read_directory( + &self, + ) -> ( + wit_bindgen::StreamReader, + wit_bindgen::FutureReader>, + ) { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.read-directory FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::ReadDirectory( + (&self.fd, self.path.clone()), + ))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + error_stream(reason) + } + Ok(Deferred) => { + let (entries, result) = self.fd.read_directory(); + let (mut tx, rx) = wit_stream::new(); + let (result_tx, result_rx) = wit_future::new(|| Err(ErrorCode::Other(None))); + let fd = self.fd.clone(); + let path = self.path.clone(); + let call_summary = call_summary(); + // a sync export can't wait for the entries, they are filtered in the background + componentized_rt::executor::spawn(async move { + let result = + match filter_directory_entries(&fd, path, entries, &mut tx, &call_summary) + .await + { + Ok(()) => result.await, + Err(code) => Err(code), + }; + // the stream ends before the result is known + drop(tx); + let _ = result_tx.write(result).await; + }); + (rx, result_rx) + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + error_stream(code.into()) + } + } + } + + #[doc = "/ Synchronize the data and metadata of a file to disk."] + #[doc = "/"] + #[doc = "/ This function succeeds with no effect if the file descriptor is not"] + #[doc = "/ opened for writing."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fsync` in POSIX."] + #[allow(async_fn_in_trait)] + async fn sync(&self) -> Result<(), ErrorCode> { + let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.sync FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::Sync(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.sync().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `mkdirat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn create_directory_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.create-directory-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor( + DescriptorOperation::CreateDirectoryAt(( + &self.fd, + self.path.clone(), + DescriptorCreateDirectoryAtArgs { path: path.clone() }, + )), + )) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.create_directory_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return the attributes of an open file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fstat` in POSIX, except that it does not return"] + #[doc = "/ device and inode information. For testing whether two descriptors refer to"] + #[doc = "/ the same underlying filesystem object, use `is-same-object`. To obtain"] + #[doc = "/ additional data that can be used do determine whether a file has been"] + #[doc = "/ modified, use `metadata-hash`."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn stat(&self) -> Result { + let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.stat FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::Stat(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.stat().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return the attributes of a file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fstatat` in POSIX, except that it does not"] + #[doc = "/ return device and inode information. See the `stat` description for a"] + #[doc = "/ discussion of alternatives."] + #[doc = "/"] + #[doc = "/ Note: This was called `path_filestat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn stat_at( + &self, + path_flags: PathFlags, + path: String, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.stat-at FD={self} PATH-FLAGS={path_flags} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::StatAt(( + &self.fd, + self.path.clone(), + DescriptorStatAtArgs { + path_flags, + path: path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.stat_at(path_flags, path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the timestamps of a file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `utimensat` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This was called `path_filestat_set_times` in earlier versions of"] + #[doc = "/ WASI."] + #[allow(async_fn_in_trait)] + async fn set_times_at( + &self, + path_flags: PathFlags, + path: String, + data_access_timestamp: NewTimestamp, + data_modification_timestamp: NewTimestamp, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.set-times-at FD={self} PATH-FLAGS={path_flags} PATH={path} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}", + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SetTimesAt(( + &self.fd, + self.path.clone(), + DescriptorSetTimesAtArgs { + path_flags, + path: path.clone(), + data_access_timestamp, + data_modification_timestamp, + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => { + self.fd + .set_times_at( + path_flags, + path, + data_access_timestamp, + data_modification_timestamp, + ) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a hard link."] + #[doc = "/"] + #[doc = "/ Fails with `error-code::no-entry` if the old path does not exist,"] + #[doc = "/ with `error-code::exist` if the new path already exists, and"] + #[doc = "/ `error-code::not-permitted` if the old path is not a file."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `linkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn link_at( + &self, + old_path_flags: PathFlags, + old_path: String, + new_descriptor: DescriptorBorrow<'_>, + new_path: String, + ) -> Result<(), ErrorCode> { + let new_descriptor: &Self = new_descriptor.get(); + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.link-at FD={self} OLD-PATH-FLAGS={old_path_flags} OLD-PATH={old_path} NEW-DESCRIPTOR={new_descriptor} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::LinkAt(( + &self.fd, + self.path.clone(), + DescriptorLinkAtArgs { + old_path_flags, + old_path: old_path.clone(), + new_descriptor: &new_descriptor.fd, + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => { + self.fd + .link_at(old_path_flags, old_path, &new_descriptor.fd, new_path) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Open a file or directory."] + #[doc = "/"] + #[doc = "/ If `flags` contains `descriptor-flags::mutate-directory`, and the base"] + #[doc = "/ descriptor doesn\'t have `descriptor-flags::mutate-directory` set,"] + #[doc = "/ `open-at` fails with `error-code::read-only`."] + #[doc = "/"] + #[doc = "/ If `flags` contains `write` or `mutate-directory`, or `open-flags`"] + #[doc = "/ contains `truncate` or `create`, and the base descriptor doesn\'t have"] + #[doc = "/ `descriptor-flags::mutate-directory` set, `open-at` fails with"] + #[doc = "/ `error-code::read-only`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `openat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn open_at( + &self, + path_flags: PathFlags, + path: String, + open_flags: OpenFlags, + flags: DescriptorFlags, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.open-at FD={self} PATH-FLAGS={path_flags} PATH={path} OPEN-FLAGS={open_flags} FLAGS={flags}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::OpenAt(( + &self.fd, + self.path.clone(), + DescriptorOpenAtArgs { + path_flags, + path: path.clone(), + open_flags, + flags, + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => { + let fd = self + .fd + .open_at(path_flags, path.clone(), open_flags, flags) + .await?; + // the host opened the path, so it is resolved against this descriptor + let follow = path_flags.contains(PathFlags::SYMLINK_FOLLOW); + let location = self + .location + .open(&path, follow, async |path: &str| { + self.fd.readlink_at(path.to_string()).await.ok() + }) + .await; + Ok(Descriptor::new(GatedDescriptor::new(fd, location))) + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Read the contents of a symbolic link."] + #[doc = "/"] + #[doc = "/ If the contents contain an absolute or rooted path in the underlying"] + #[doc = "/ filesystem, this function fails with `error-code::not-permitted`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `readlinkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn readlink_at(&self, path: String) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.readlink-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::ReadlinkAt(( + &self.fd, + self.path.clone(), + DescriptorReadlinkAtArgs { path: path.clone() }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.readlink_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Remove a directory."] + #[doc = "/"] + #[doc = "/ Return `error-code::not-empty` if the directory is not empty."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX."] + #[allow(async_fn_in_trait)] + async fn remove_directory_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.remove-directory-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor( + DescriptorOperation::RemoveDirectoryAt(( + &self.fd, + self.path.clone(), + DescriptorRemoveDirectoryAtArgs { path: path.clone() }, + )), + )) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.remove_directory_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Rename a filesystem object."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `renameat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn rename_at( + &self, + old_path: String, + new_descriptor: DescriptorBorrow<'_>, + new_path: String, + ) -> Result<(), ErrorCode> { + let new_descriptor: &Self = new_descriptor.get(); + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.rename-at FD={self} OLD-PATH={old_path} NEW-DESCRIPTOR={new_descriptor} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::RenameAt(( + &self.fd, + self.path.clone(), + DescriptorRenameAtArgs { + old_path: old_path.clone(), + new_descriptor: &new_descriptor.fd, + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => { + self.fd + .rename_at(old_path, &new_descriptor.fd, new_path) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a symbolic link (also known as a \"symlink\")."] + #[doc = "/"] + #[doc = "/ If `old-path` starts with `/`, the function fails with"] + #[doc = "/ `error-code::not-permitted`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `symlinkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn symlink_at(&self, old_path: String, new_path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.symlink-at FD={self} OLD-PATH={old_path} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SymlinkAt(( + &self.fd, + self.path.clone(), + DescriptorSymlinkAtArgs { + old_path: old_path.clone(), + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.symlink_at(old_path, new_path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Unlink a filesystem object that is not a directory."] + #[doc = "/"] + #[doc = "/ This is similar to `unlinkat(fd, path, 0)` in POSIX."] + #[doc = "/"] + #[doc = "/ Error returns are as specified by POSIX."] + #[doc = "/"] + #[doc = "/ If the filesystem object is a directory, `error-code::access` or"] + #[doc = "/ `error-code::is-directory` may be returned instead of the"] + #[doc = "/ POSIX-specified `error-code::not-permitted`."] + #[allow(async_fn_in_trait)] + async fn unlink_file_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.unlink-file-at FD={self} PATH={path}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::UnlinkFileAt(( + &self.fd, + self.path.clone(), + DescriptorUnlinkFileAtArgs { path: path.clone() }, + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.unlink_file_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Test whether two descriptors refer to the same filesystem object."] + #[doc = "/"] + #[doc = "/ In POSIX, this corresponds to testing whether the two descriptors have the"] + #[doc = "/ same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers."] + #[doc = "/ wasi-filesystem does not expose device and inode numbers, so this function"] + #[doc = "/ may be used instead."] + #[allow(async_fn_in_trait)] + async fn is_same_object(&self, other: DescriptorBorrow<'_>) -> bool { + let other: &Self = other.get(); + self.fd.is_same_object(&other.fd).await + } + + #[doc = "/ Return a hash of the metadata associated with a filesystem object referred"] + #[doc = "/ to by a descriptor."] + #[doc = "/"] + #[doc = "/ This returns a hash of the last-modification timestamp and file size, and"] + #[doc = "/ may also include the inode number, device number, birth timestamp, and"] + #[doc = "/ other metadata fields that may change when the file is modified or"] + #[doc = "/ replaced. It may also include a secret value chosen by the"] + #[doc = "/ implementation and not otherwise exposed."] + #[doc = "/"] + #[doc = "/ Implementations are encouraged to provide the following properties:"] + #[doc = "/"] + #[doc = "/ - If the file is not modified or replaced, the computed hash value should"] + #[doc = "/ usually not change."] + #[doc = "/ - If the object is modified or replaced, the computed hash value should"] + #[doc = "/ usually change."] + #[doc = "/ - The inputs to the hash should not be easily computable from the"] + #[doc = "/ computed hash."] + #[doc = "/"] + #[doc = "/ However, none of these is required."] + #[allow(async_fn_in_trait)] + async fn metadata_hash(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.metadata-hash FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHash(( + &self.fd, + self.path.clone(), + )))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.metadata_hash().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return a hash of the metadata associated with a filesystem object referred"] + #[doc = "/ to by a directory descriptor and a relative path."] + #[doc = "/"] + #[doc = "/ This performs the same hash computation as `metadata-hash`."] + #[allow(async_fn_in_trait)] + async fn metadata_hash_at( + &self, + path_flags: PathFlags, + path: String, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.metadata-hash-at FD={self} PATH-FLAGS={path_flags} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHashAt( + ( + &self.fd, + self.path.clone(), + DescriptorMetadataHashAtArgs { + path_flags, + path: path.clone(), + }, + ), + ))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); + Err(reason) + } + Ok(Deferred) => self.fd.metadata_hash_at(path_flags, path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), + summary = call_summary() + ); + Err(code)? + } + } + } +} + +/// Forward the directory entries the latch does not deny, until either end of the stream is +/// dropped. A latch error ends the stream and fails the read. +async fn filter_directory_entries( + fd: &types::Descriptor, + path: String, + mut entries: StreamReader, + to: &mut wit_bindgen::StreamWriter, + call_summary: &str, +) -> Result<(), ErrorCode> { + loop { + let (status, batch) = entries.read(Vec::with_capacity(64)).await; + let mut allowed = Vec::with_capacity(batch.len()); + for entry in batch { + match authorize(&Operation::Descriptor( + DescriptorOperation::ReadDirectoryEntry((fd, path.clone(), entry.clone())), + )) { + Ok(Denied(reason)) => { + trace!( + "Denied REASON={reason} {call_summary} NAME={name} TYPE={type_}", + reason = DisplayErrorCode(&reason), + name = entry.name, + type_ = entry.type_ + ); + } + Ok(Deferred) => allowed.push(entry), + Err(code) => { + error!( + "Latch error CODE={code} {call_summary} NAME={name} TYPE={type_}", + code = DisplayLatchErrorCode(code.clone()), + name = entry.name, + type_ = entry.type_ + ); + return Err(code.into()); + } + } + } + if !allowed.is_empty() && !to.write_all(allowed).await.is_empty() { + // the reader was dropped + return Ok(()); + } + if !matches!(status, StreamResult::Complete(_)) { + return Ok(()); + } + } +} + +/// A future resolved to `err`, for the sync exports that return a future. +fn error_future(code: ErrorCode) -> FutureReader> { + // the default is only written if the write is cancelled + let (tx, rx) = wit_future::new(|| Err(ErrorCode::Other(None))); + // a sync export can't wait for the write, the reader has not been returned yet, so the write + // completes in the background + componentized_rt::executor::spawn(async move { + let _ = tx.write(Err(code)).await; + }); + rx +} + +/// An empty stream with a future resolved to `err`, for the sync exports that return a stream. +fn error_stream( + code: ErrorCode, +) -> (StreamReader, FutureReader>) +where + T: wit_stream::StreamPayload, +{ + // dropping the writer closes the stream without items + let (_, rx) = wit_stream::new::(); + (rx, error_future(code)) +} + +impl From for ErrorCode { + fn from(value: LatchErrorCode) -> Self { + match value { + LatchErrorCode::InvalidConfig(latch) => { + Self::Other(Some(format!("latch-error: invalid-config<{latch}>"))) + } + LatchErrorCode::ObservationFailed(latch) => { + Self::Other(Some(format!("latch-error: observation-failed<{latch}>"))) + } + LatchErrorCode::Other(Some(message)) => { + Self::Other(Some(format!("latch-error: {message}"))) + } + LatchErrorCode::Other(None) => Self::Other(Some("latch-error".to_string())), + } + } +} + +/// Displays a denial reason the way the gate logs it, e.g. `not-permitted`. +/// +/// The generated bindings already implement `Display` for [`ErrorCode`], with its debug form. +struct DisplayErrorCode<'a>(&'a ErrorCode); +impl std::fmt::Display for DisplayErrorCode<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(match self.0 { + ErrorCode::Access => "access", + ErrorCode::Already => "already", + ErrorCode::BadDescriptor => "bad-descriptor", + ErrorCode::Busy => "busy", + ErrorCode::Deadlock => "deadlock", + ErrorCode::Quota => "quota", + ErrorCode::Exist => "exist", + ErrorCode::FileTooLarge => "file-too-large", + ErrorCode::IllegalByteSequence => "illegal-byte-sequence", + ErrorCode::InProgress => "in-progress", + ErrorCode::Interrupted => "interrupted", + ErrorCode::Invalid => "invalid", + ErrorCode::Io => "io", + ErrorCode::IsDirectory => "is-directory", + ErrorCode::Loop => "loop", + ErrorCode::TooManyLinks => "too-many-links", + ErrorCode::MessageSize => "message-size", + ErrorCode::NameTooLong => "name-too-long", + ErrorCode::NoDevice => "no-device", + ErrorCode::NoEntry => "no-entry", + ErrorCode::NoLock => "no-lock", + ErrorCode::InsufficientMemory => "insufficient-memory", + ErrorCode::InsufficientSpace => "insufficient-space", + ErrorCode::NotDirectory => "not-directory", + ErrorCode::NotEmpty => "not-empty", + ErrorCode::NotRecoverable => "not-recoverable", + ErrorCode::Unsupported => "unsupported", + ErrorCode::NoTty => "no-tty", + ErrorCode::NoSuchDevice => "no-such-device", + ErrorCode::Overflow => "overflow", + ErrorCode::NotPermitted => "not-permitted", + ErrorCode::Pipe => "pipe", + ErrorCode::ReadOnly => "read-only", + ErrorCode::InvalidSeek => "invalid-seek", + ErrorCode::TextFileBusy => "text-file-busy", + ErrorCode::CrossDevice => "cross-device", + ErrorCode::Other(Some(message)) => message, + ErrorCode::Other(None) => "other", + }) + } +} + +struct DisplayLatchErrorCode(LatchErrorCode); +impl std::fmt::Display for DisplayLatchErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match &self.0 { + LatchErrorCode::InvalidConfig(latch) => { + write!(f, "invalid-config<{latch}>") + } + LatchErrorCode::ObservationFailed(latch) => { + write!(f, "observation-failed<{latch}>") + } + LatchErrorCode::Other(Some(message)) => f.write_str(message), + LatchErrorCode::Other(None) => f.write_str("other"), + } + } +} + +impl Display for GatedDescriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.location.fmt(f) + } +} + +impl Display for types::Advice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + types::Advice::Normal => "normal", + types::Advice::Sequential => "sequential", + types::Advice::Random => "random", + types::Advice::WillNeed => "will-need", + types::Advice::DontNeed => "dont-need", + types::Advice::NoReuse => "no-reuse", + }) + } +} + +impl Display for types::DescriptorFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::OpenFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::PathFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::NewTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + types::NewTimestamp::NoChange => f.write_str("no-change"), + types::NewTimestamp::Now => f.write_str("now"), + types::NewTimestamp::Timestamp(instant) => { + f.write_fmt(format_args!("timestamp<{instant}>")) + } + } + } +} + +impl Display for types::Instant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let date = DateTime::from_timestamp(self.seconds as i64, self.nanoseconds) + .unwrap() + .format("%Y-%m-%d %H:%M:%S.%3fZ"); + + f.write_fmt(format_args!("{date}")) + } +} + +impl Display for types::DescriptorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self { + types::DescriptorType::BlockDevice => f.write_str("block-device"), + types::DescriptorType::CharacterDevice => f.write_str("character-device"), + types::DescriptorType::Directory => f.write_str("directory"), + types::DescriptorType::Fifo => f.write_str("fifo"), + types::DescriptorType::SymbolicLink => f.write_str("symbolic-link"), + types::DescriptorType::RegularFile => f.write_str("regular-file"), + types::DescriptorType::Socket => f.write_str("socket"), + types::DescriptorType::Other(None) => f.write_str("other"), + types::DescriptorType::Other(Some(type_)) => { + write!(f, "other<{type_}>") + } + } + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "filesystem", + merge_structurally_equal_types: true, + generate_all +}); + +export!(GatedFilesystem); diff --git a/components/gate/src/path.rs b/components/gate/src/path.rs new file mode 100644 index 0000000..6fed128 --- /dev/null +++ b/components/gate/src/path.rs @@ -0,0 +1,218 @@ +//! The physical path of a descriptor, for its label in the trace. +//! +//! A descriptor opened with `open-at` is labeled with the path of the directory or file it is, the +//! path `PATH` arguments of later calls are resolved against: `.` and `..` are applied, and +//! symlinks are followed where the host follows them, every component but the last, and the last +//! with `symlink-follow`. + +/// The most symlinks followed resolving one path, more is a loop, see `ELOOP` in POSIX. +const MAX_SYMLINKS: usize = 40; + +/// Where a descriptor is, the preopen it was opened from and the components of its path within it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + preopen: String, + components: Vec, +} + +impl Location { + /// The location of a preopen, labeled with its name, e.g. `/` or `.`. + pub fn preopen(name: &str) -> Self { + Self { + preopen: name.to_string(), + components: vec![], + } + } + + /// Resolve a path opened from this location. + /// + /// `readlink` reads a symlink at a path relative to this location, `None` when the path is not + /// a symlink. The host already opened the path, so it names a location below this one; a + /// symlink with an absolute target, which the host doesn't follow, is left unresolved. + pub async fn open( + &self, + path: &str, + follow: bool, + mut readlink: impl AsyncFnMut(&str) -> Option, + ) -> Self { + // components relative to this location, still to be resolved, and resolved + let mut pending: Vec = split(path).rev().map(String::from).collect(); + let mut resolved: Vec = vec![]; + let mut symlinks = 0; + while let Some(component) = pending.pop() { + match component.as_str() { + "." => {} + ".." => { + resolved.pop(); + } + _ => { + resolved.push(component); + // the last component is only followed with `symlink-follow` + if (!pending.is_empty() || follow) && symlinks < MAX_SYMLINKS { + if let Some(target) = readlink(&resolved.join("/")).await { + if !target.starts_with('/') { + symlinks += 1; + // the target is relative to the directory containing the link + resolved.pop(); + pending.extend(split(&target).rev().map(String::from)); + } + } + } + } + } + } + let mut components = self.components.clone(); + components.extend(resolved); + Self { + preopen: self.preopen.clone(), + components, + } + } +} + +/// The components of a path, without empty components from repeated or trailing separators. +fn split(path: &str) -> impl DoubleEndedIterator { + path.split('/').filter(|component| !component.is_empty()) +} + +impl std::fmt::Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.preopen)?; + for (index, component) in self.components.iter().enumerate() { + if index > 0 || !self.preopen.ends_with('/') { + f.write_str("/")?; + } + f.write_str(component)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + use super::*; + + /// Run a future that never waits, as the fake readlink never does. + fn now(future: impl Future) -> T { + match pin!(future).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(value) => value, + Poll::Pending => panic!("the future waited"), + } + } + + /// Open `path` from `from`, with the symlinks of a filesystem, by their path relative to `from`. + fn open(from: &Location, path: &str, follow: bool, links: &[(&str, &str)]) -> String { + let links: HashMap<&str, &str> = links.iter().copied().collect(); + let location = now(from.open(path, follow, async |path: &str| { + links.get(path).map(|target| target.to_string()) + })); + location.to_string() + } + + fn root() -> Location { + Location::preopen("/") + } + + #[test] + fn preopens_are_labeled_with_their_name() { + assert_eq!(Location::preopen("/").to_string(), "/"); + assert_eq!(Location::preopen(".").to_string(), "."); + assert_eq!(Location::preopen("/data").to_string(), "/data"); + } + + #[test] + fn relative_paths_are_joined() { + assert_eq!(open(&root(), "dir", true, &[]), "/dir"); + assert_eq!(open(&root(), "dir/sub", true, &[]), "/dir/sub"); + let data = Location::preopen("/data"); + assert_eq!(open(&data, "dir", true, &[]), "/data/dir"); + let dot = Location::preopen("."); + assert_eq!(open(&dot, "dir/sub", true, &[]), "./dir/sub"); + } + + #[test] + fn dots_and_separators_are_resolved() { + assert_eq!(open(&root(), ".", true, &[]), "/"); + assert_eq!(open(&root(), "./dir/./sub/.", true, &[]), "/dir/sub"); + assert_eq!(open(&root(), "dir//sub/", true, &[]), "/dir/sub"); + assert_eq!(open(&root(), "dir/sub/..", true, &[]), "/dir"); + assert_eq!(open(&root(), "dir/../other", true, &[]), "/other"); + assert_eq!(open(&root(), "dir/sub/../..", true, &[]), "/"); + } + + #[test] + fn paths_resolve_from_the_descriptor() { + let dir = now(root().open("dir", true, async |_: &str| None)); + assert_eq!(open(&dir, "sub", true, &[]), "/dir/sub"); + assert_eq!(open(&dir, ".", true, &[]), "/dir"); + assert_eq!(open(&dir, "sub/..", true, &[]), "/dir"); + } + + #[test] + fn symlinks_are_followed() { + let links = [("link", "dir")]; + assert_eq!(open(&root(), "link", true, &links), "/dir"); + assert_eq!(open(&root(), "link/sub", true, &links), "/dir/sub"); + // `..` of the link's target, not of the directory containing the link + let links = [("link", "dir/sub")]; + assert_eq!(open(&root(), "link/..", true, &links), "/dir"); + } + + #[test] + fn the_last_symlink_is_only_followed_with_the_flag() { + let links = [("link", "dir")]; + assert_eq!(open(&root(), "link", false, &links), "/link"); + // intermediate symlinks are always followed + assert_eq!(open(&root(), "link/sub", false, &links), "/dir/sub"); + } + + #[test] + fn symlink_targets_are_relative_to_the_link() { + let links = [("dir/up", "../other")]; + assert_eq!(open(&root(), "dir/up", true, &links), "/other"); + let links = [("dir/self", ".")]; + assert_eq!(open(&root(), "dir/self/sub", true, &links), "/dir/sub"); + let links = [("dir/sub-link", "sub/")]; + assert_eq!(open(&root(), "dir/sub-link", true, &links), "/dir/sub"); + } + + #[test] + fn chained_symlinks_are_followed() { + let links = [ + ("first", "second"), + ("second", "dir/third"), + ("dir/third", "../target"), + ]; + assert_eq!(open(&root(), "first", true, &links), "/target"); + assert_eq!( + open(&root(), "first/file.txt", true, &links), + "/target/file.txt" + ); + } + + #[test] + fn symlinks_resolve_from_the_descriptor() { + let dir = now(root().open("dir", true, async |_: &str| None)); + // readlink is given paths relative to the descriptor + let links = [("link", "sub")]; + assert_eq!(open(&dir, "link", true, &links), "/dir/sub"); + } + + #[test] + fn absolute_symlinks_are_not_followed() { + let links = [("link", "/etc")]; + assert_eq!(open(&root(), "link", true, &links), "/link"); + } + + #[test] + fn symlink_loops_end() { + let links = [("a", "b"), ("b", "a")]; + // the host fails to open a loop, but resolution must still end + let _ = open(&root(), "a", true, &links); + } +} diff --git a/components/gate/tests/gate.rs b/components/gate/tests/gate.rs new file mode 100644 index 0000000..b16a46d --- /dev/null +++ b/components/gate/tests/gate.rs @@ -0,0 +1,928 @@ +//! The gate asks its latch to authorize every filesystem call, and passes the call through unless +//! the latch denies it. A denied call fails with the reason the latch gave, a latch error fails it +//! with `other`, and both are logged in the `componentized-gate` context. +//! +//! The `latch-deny-all` and `latch-defer-all` components are the gate's latch, alone and +//! aggregated. A scripted host latch denies or fails individual operations, and records the +//! operations it was asked about. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! empty/ +//! dir/nested.txt "nested" +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{ + Advice, DirectoryEntry, GuestDescriptor, NewTimestamp, +}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, HostLatch, LogEntry, + Observation, OpenFlags, PathFlags, Perms, TestSubject, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +const CONTEXT: &str = "componentized-gate"; +const TYPES: &str = "OPERATION=wasi:filesystem/types#descriptor"; + +/// Every descriptor operation but `open-at`, which the tests need to open their handles. +const DESCRIPTOR_OPERATIONS: &[&str] = &[ + "descriptor.read-via-stream", + "descriptor.write-via-stream", + "descriptor.append-via-stream", + "descriptor.advise", + "descriptor.sync-data", + "descriptor.get-flags", + "descriptor.get-type", + "descriptor.set-size", + "descriptor.set-times", + "descriptor.read-directory", + "descriptor.sync", + "descriptor.create-directory-at", + "descriptor.stat", + "descriptor.stat-at", + "descriptor.set-times-at", + "descriptor.link-at", + "descriptor.readlink-at", + "descriptor.remove-directory-at", + "descriptor.rename-at", + "descriptor.symlink-at", + "descriptor.unlink-file-at", + "descriptor.metadata-hash", + "descriptor.metadata-hash-at", +]; + +struct Fixture { + dir: TempDir, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("file.txt"), "hello").unwrap(); + symlink("file.txt", root.join("link")).unwrap(); + fs::create_dir(root.join("empty")).unwrap(); + fs::create_dir(root.join("dir")).unwrap(); + fs::write(root.join("dir/nested.txt"), "nested").unwrap(); + Self { dir } + } + + fn path(&self) -> &Path { + self.dir.path() + } + + /// The gate with the fixture preopened as `/`. + fn gate(&self) -> Harness { + Harness::new("gate").preopen(self.path(), "/", Perms::ReadWrite) + } +} + +/// Descriptors opened through the gate. +#[derive(Clone, Copy)] +struct Handles { + /// The preopen, `/` + root: ResourceAny, + /// `/dir` + dir: ResourceAny, + /// `/file.txt`, opened for reading and writing + file: ResourceAny, +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn preopens( + accessor: &Accessor, + exports: &Exports, +) -> wasmtime::Result> { + exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await +} + +async fn open( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, + path: &str, + open_flags: OpenFlags, + flags: DescriptorFlags, +) -> wasmtime::Result> { + descriptor(exports) + .call_open_at( + accessor, + fd, + PathFlags::empty(), + path.into(), + open_flags, + flags, + ) + .await +} + +async fn handles(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let (root, _) = preopens(accessor, exports) + .await? + .into_iter() + .next() + .expect("a preopen"); + let read = DescriptorFlags::READ; + let dir = open(accessor, exports, root, "dir", OpenFlags::DIRECTORY, read) + .await? + .expect("open dir"); + let file = open( + accessor, + exports, + root, + "file.txt", + OpenFlags::empty(), + read | DescriptorFlags::WRITE, + ) + .await? + .expect("open file.txt"); + Ok(Handles { root, dir, file }) +} + +/// Build the gate and run the test body against handles opened through it. +async fn run( + gate: Harness, + f: impl AsyncFnOnce(&Accessor, &Exports, Handles) -> wasmtime::Result + Send, +) -> (R, TestSubject) { + let mut gate = gate.build().await.expect("gate"); + let result = gate + .run(async |accessor, exports| { + let handles = handles(accessor, exports).await?; + f(accessor, exports, handles).await + }) + .await + .expect("run"); + (result, gate) +} + +/// The bytes of a stream the gate returned. +async fn read_bytes( + accessor: &Accessor, + data: wasmtime::component::StreamReader, +) -> wasmtime::Result> { + let mut data = test_harness::collect(accessor, data)?; + let mut bytes = vec![]; + while let Some(byte) = data.recv().await { + bytes.push(byte); + } + Ok(bytes) +} + +/// The entries of a directory stream the gate returned, sorted by name, and the read's result. +async fn read_entries( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, +) -> wasmtime::Result<(Vec, Result<(), ErrorCode>)> { + let (entries, result) = descriptor(exports) + .call_read_directory(accessor, fd) + .await?; + let mut entries = test_harness::collect(accessor, entries)?; + let mut names = vec![]; + while let Some(DirectoryEntry { name, .. }) = entries.recv().await { + names.push(name); + } + names.sort(); + Ok((names, test_harness::resolve(accessor, result).await?)) +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +fn warn(message: impl Into) -> LogEntry { + LogEntry::warn(CONTEXT, message) +} + +fn trace(message: impl Into) -> LogEntry { + LogEntry::trace(CONTEXT, message) +} + +fn error(message: impl Into) -> LogEntry { + LogEntry::error(CONTEXT, message) +} + +// --- latch-deny-all --- + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_hides_every_preopen() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .latch("latch-deny-all") + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert!(names.is_empty(), "{names:?}"); + assert_eq!( + gate.recorder().logs(), + [warn( + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_wins_over_defer_all() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .latch("latch-defer-all") + .latch("latch-deny-all") + .build() + .await + .unwrap(); + let dirs = gate + .run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await + .unwrap(); + assert_eq!(dirs, 0); + assert_eq!( + gate.recorder().logs(), + [warn( + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_decision_is_observed_by_every_latch() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await + .unwrap(); + gate.run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await + .unwrap(); + let recorder = gate.recorder(); + // the host latch is aggregated after latch-deny-all, it is not asked once the call is denied + assert!(recorder.operations().is_empty(), "{:?}", recorder.operations()); + assert_eq!( + recorder.observations(), + [Observation { + operation: "preopens.get-directories".into(), + denied: true, + }] + ); +} + +// --- latch-defer-all --- + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_preopens_through() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .latch("latch-defer-all") + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert_eq!(names, ["/", "/data"]); + assert_eq!(gate.recorder().logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_reads_through() { + let fixture = Fixture::new(); + let ((data, offset, entries, stat, stat_at, kind, hashes, target, advised), gate) = run( + fixture.gate().latch("latch-defer-all"), + async |accessor, exports, h| { + let d = descriptor(exports); + let (data, _) = d.call_read_via_stream(accessor, h.file, 0).await?; + let data = read_bytes(accessor, data).await?; + let (offset, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + let offset = read_bytes(accessor, offset).await?; + let entries = read_entries(accessor, exports, h.root).await?; + let stat = d.call_stat(accessor, h.file).await?.expect("stat"); + let stat_at = d + .call_stat_at(accessor, h.root, PathFlags::empty(), "link".into()) + .await? + .expect("stat-at"); + let kind = d.call_get_type(accessor, h.dir).await?; + let hashes = ( + d.call_metadata_hash(accessor, h.file).await?, + d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?, + ); + let target = d.call_readlink_at(accessor, h.root, "link".into()).await?; + let advised = d + .call_advise(accessor, h.file, 0, 0, Advice::Sequential) + .await?; + Ok(( + data, offset, entries, stat, stat_at, kind, hashes, target, advised, + )) + }, + ) + .await; + assert_eq!(data, b"hello"); + assert_eq!(offset, b"ello"); + assert_eq!( + entries, + ( + vec!["dir".into(), "empty".into(), "file.txt".into(), "link".into()], + Ok(()) + ) + ); + assert_eq!(stat.type_, DescriptorType::RegularFile); + assert_eq!(stat.size, 5); + assert_eq!(stat_at.type_, DescriptorType::SymbolicLink); + assert_eq!(kind, Ok(DescriptorType::Directory)); + assert!(hashes.0.is_ok(), "{hashes:?}"); + assert_eq!(hashes.0, hashes.1); + assert_eq!(target, Ok("file.txt".into())); + assert_eq!(advised, Ok(())); + assert_eq!(gate.recorder().logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_writes_through() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().latch("latch-defer-all"), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + Ok(vec![ + ok(d.call_get_flags(accessor, h.file).await?), + d.call_set_size(accessor, h.file, 2).await?, + d.call_set_times(accessor, h.file, now(), now()).await?, + d.call_sync_data(accessor, h.file).await?, + d.call_sync(accessor, h.file).await?, + d.call_create_directory_at(accessor, h.root, "new".into()) + .await?, + d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "new".into(), + now(), + now(), + ) + .await?, + d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?, + d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?, + d.call_rename_at(accessor, h.root, "new".into(), h.dir, "renamed".into()) + .await?, + d.call_remove_directory_at(accessor, h.dir, "renamed".into()) + .await?, + d.call_unlink_file_at(accessor, h.root, "new-link".into()) + .await?, + d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?, + ]) + }, + ) + .await; + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Ok(()), "operation {index}"); + } + let root = fixture.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"he"); + assert_eq!(fs::read(root.join("dir/hard.txt")).unwrap(), b"he"); + assert!(!root.join("new").exists()); + assert!(!root.join("dir/renamed").exists()); + assert!(!root.join("new-link").exists()); + assert!(!root.join("empty").exists()); + assert_eq!(gate.recorder().logs(), []); +} + +// --- authorization --- + +#[tokio::test(flavor = "multi_thread")] +async fn every_operation_is_authorized_and_observed() { + let fixture = Fixture::new(); + let (_, gate) = run( + fixture.gate().host_latch(HostLatch::defer()), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + let (data, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + read_bytes(accessor, data).await?; + let data = test_harness::stream(accessor, b"!".to_vec())?; + let _ = d.call_write_via_stream(accessor, h.file, data, 5).await?; + let data = test_harness::stream(accessor, b"!".to_vec())?; + let _ = d.call_append_via_stream(accessor, h.file, data).await?; + let _ = d.call_advise(accessor, h.file, 0, 0, Advice::Random).await?; + let _ = d.call_sync_data(accessor, h.file).await?; + let _ = d.call_get_flags(accessor, h.file).await?; + let _ = d.call_get_type(accessor, h.file).await?; + let _ = d.call_set_size(accessor, h.file, 5).await?; + let _ = d.call_set_times(accessor, h.file, now(), NewTimestamp::NoChange) + .await?; + let _ = read_entries(accessor, exports, h.dir).await?; + let _ = d.call_sync(accessor, h.file).await?; + let _ = d.call_create_directory_at(accessor, h.root, "new".into()) + .await?; + let _ = d.call_stat(accessor, h.file).await?; + let _ = d.call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?; + let _ = d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "new".into(), + NewTimestamp::NoChange, + now(), + ) + .await?; + let _ = d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?; + let _ = d.call_readlink_at(accessor, h.root, "link".into()).await?; + let _ = d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?; + let _ = d.call_rename_at(accessor, h.root, "new".into(), h.dir, "renamed".into()) + .await?; + let _ = d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?; + let _ = d.call_unlink_file_at(accessor, h.root, "new-link".into()) + .await?; + let _ = d.call_metadata_hash(accessor, h.file).await?; + let _ = d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?; + Ok(()) + }, + ) + .await; + let recorder = gate.recorder(); + let operations = recorder.operations(); + let expected: Vec<&str> = [ + "preopens.get-directories", + "preopens.get-directories-item", + "descriptor.open-at", + "descriptor.open-at", + ] + .into_iter() + .chain(DESCRIPTOR_OPERATIONS.iter().copied().flat_map(|op| match op { + // the entries of `dir` are authorized one by one + "descriptor.read-directory" => vec![op, "descriptor.read-directory-entry"], + _ => vec![op], + })) + .collect(); + assert_eq!(operations, expected); + // every decision is observed, in the order it was made + assert_eq!( + recorder.observations(), + operations + .iter() + .map(|operation| Observation { + operation: operation.clone(), + denied: false, + }) + .collect::>() + ); + assert_eq!(recorder.logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn operations_are_authorized_with_their_args() { + let fixture = Fixture::new(); + let (_, gate) = run( + fixture.gate().host_latch(HostLatch::defer()), + async |accessor, exports, h| { + let d = descriptor(exports); + let (data, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + read_bytes(accessor, data).await?; + let _ = d.call_set_size(accessor, h.file, 5).await?; + let _ = read_entries(accessor, exports, h.dir).await?; + let _ = d.call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?; + let _ = d.call_rename_at(accessor, h.root, "empty".into(), h.dir, "renamed".into()) + .await?; + Ok(()) + }, + ) + .await; + let args: Vec<(String, String)> = gate + .recorder() + .authorizations() + .into_iter() + .map(|auth| (auth.operation, auth.args)) + .collect(); + let args: Vec<(&str, &str)> = args + .iter() + .map(|(operation, args)| (operation.as_str(), args.as_str())) + .collect(); + assert_eq!( + args, + [ + ("preopens.get-directories", ""), + ("preopens.get-directories-item", "\"/\""), + ( + "descriptor.open-at", + "DescriptorOpenAtArgs { path-flags: (), path: \"dir\", open-flags: (DIRECTORY), flags: (READ) }" + ), + ( + "descriptor.open-at", + "DescriptorOpenAtArgs { path-flags: (), path: \"file.txt\", open-flags: (), flags: (READ|WRITE) }" + ), + ( + "descriptor.read-via-stream", + "DescriptorReadViaStreamArgs { offset: 1 }" + ), + ("descriptor.set-size", "DescriptorSetSizeArgs { size: 5 }"), + ("descriptor.read-directory", ""), + ( + "descriptor.read-directory-entry", + "DirectoryEntry { type: DescriptorType::RegularFile, name: \"nested.txt\" }" + ), + ( + "descriptor.stat-at", + "DescriptorStatAtArgs { path-flags: (SYMLINK_FOLLOW), path: \"link\" }" + ), + ( + "descriptor.rename-at", + "old_path: \"empty\", new_path: \"renamed\"" + ), + ] + ); +} + +// --- denied --- + +#[tokio::test(flavor = "multi_thread")] +async fn denied_operations_fail_with_the_reason_and_are_logged() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().host_latch(HostLatch::deny(DESCRIPTOR_OPERATIONS)), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + let mut results = vec![]; + + let (data, result) = d.call_read_via_stream(accessor, h.file, 1).await?; + assert_eq!(read_bytes(accessor, data).await?, b"", "read-via-stream data"); + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_write_via_stream(accessor, h.file, data, 5).await?; + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_append_via_stream(accessor, h.file, data).await?; + results.push(test_harness::resolve(accessor, result).await?); + results.push(d.call_advise(accessor, h.file, 0, 0, Advice::Random).await?); + results.push(d.call_sync_data(accessor, h.file).await?); + results.push(ok(d.call_get_flags(accessor, h.file).await?)); + results.push(ok(d.call_get_type(accessor, h.file).await?)); + results.push(d.call_set_size(accessor, h.file, 0).await?); + results.push( + d.call_set_times(accessor, h.file, now(), NewTimestamp::NoChange) + .await?, + ); + let (entries, result) = read_entries(accessor, exports, h.dir).await?; + assert!(entries.is_empty(), "read-directory entries {entries:?}"); + results.push(result); + results.push(d.call_sync(accessor, h.file).await?); + results.push( + d.call_create_directory_at(accessor, h.root, "new".into()) + .await?, + ); + results.push(ok(d.call_stat(accessor, h.file).await?)); + results.push(ok(d + .call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?)); + results.push( + d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + NewTimestamp::NoChange, + now(), + ) + .await?, + ); + results.push( + d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?, + ); + results.push(ok(d + .call_readlink_at(accessor, h.root, "link".into()) + .await?)); + results.push( + d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?, + ); + results.push( + d.call_rename_at(accessor, h.root, "empty".into(), h.dir, "renamed".into()) + .await?, + ); + results.push( + d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?, + ); + results.push( + d.call_unlink_file_at(accessor, h.root, "link".into()) + .await?, + ); + results.push(ok(d.call_metadata_hash(accessor, h.file).await?)); + results.push(ok(d + .call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?)); + Ok(results) + }, + ) + .await; + assert_eq!(results.len(), DESCRIPTOR_OPERATIONS.len()); + for (operation, result) in DESCRIPTOR_OPERATIONS.iter().zip(results) { + assert_eq!(result, Err(ErrorCode::Access), "{operation}"); + } + + // nothing was changed + let root = fixture.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"hello"); + assert!(root.join("empty").is_dir()); + assert!(root.join("link").is_symlink()); + assert!(!root.join("new").exists()); + assert!(!root.join("dir/hard.txt").exists()); + + let denied = |call: &str| warn(format!("Denied REASON=access {TYPES}.{call}")); + assert_eq!( + gate.recorder().logs(), + [ + denied("read-via-stream FD=/file.txt OFFSET=1"), + denied("write-via-stream FD=/file.txt OFFSET=5"), + denied("append-via-stream FD=/file.txt"), + denied("advise FD=/file.txt OFFSET=0 LENGTH=0 ADVICE=random"), + denied("sync-data FD=/file.txt"), + denied("get-flags FD=/file.txt"), + denied("get-type FD=/file.txt"), + denied("set-size FD=/file.txt SIZE=0"), + denied( + "set-times FD=/file.txt ACCESS-TIMESTAMP=now MODIFICATION-TIMESTAMP=no-change" + ), + denied("read-directory FD=/dir"), + denied("sync FD=/file.txt"), + denied("create-directory-at FD=/ PATH=new"), + denied("stat FD=/file.txt"), + denied("stat-at FD=/ PATH-FLAGS=(symlink-follow) PATH=link"), + denied( + "set-times-at FD=/ PATH-FLAGS=() PATH=file.txt ACCESS-TIMESTAMP=no-change MODIFICATION-TIMESTAMP=now" + ), + denied( + "link-at FD=/ OLD-PATH-FLAGS=() OLD-PATH=file.txt NEW-DESCRIPTOR=/dir NEW-PATH=hard.txt" + ), + denied("readlink-at FD=/ PATH=link"), + denied("remove-directory-at FD=/ PATH=empty"), + denied("rename-at FD=/ OLD-PATH=empty NEW-DESCRIPTOR=/dir NEW-PATH=renamed"), + denied("symlink-at FD=/ OLD-PATH=file.txt NEW-PATH=new-link"), + denied("unlink-file-at FD=/ PATH=link"), + denied("metadata-hash FD=/file.txt"), + denied("metadata-hash-at FD=/ PATH-FLAGS=() PATH=file.txt"), + ] + ); + // denials are observed too + let observations = gate.recorder().observations(); + let denied: Vec<&str> = observations + .iter() + .filter(|observation| observation.denied) + .map(|observation| observation.operation.as_str()) + .collect(); + assert_eq!(denied, DESCRIPTOR_OPERATIONS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_open_fails_with_the_reason_and_is_logged() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .host_latch(HostLatch::deny(&["descriptor.open-at"])) + .build() + .await + .unwrap(); + let result = gate + .run(async |accessor, exports| { + let (root, _) = preopens(accessor, exports).await?.into_iter().next().unwrap(); + Ok(ok(open( + accessor, + exports, + root, + "file.txt", + OpenFlags::CREATE, + DescriptorFlags::READ | DescriptorFlags::WRITE, + ) + .await?)) + }) + .await + .unwrap(); + assert_eq!(result, Err(ErrorCode::Access)); + assert_eq!( + gate.recorder().logs(), + [warn(format!( + "Denied REASON=access {TYPES}.open-at FD=/ PATH-FLAGS=() PATH=file.txt OPEN-FLAGS=(create) FLAGS=(read|write)" + ))] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_preopens_are_omitted_and_logged() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .host_latch(HostLatch::new(|auth| { + if auth.operation == "preopens.get-directories-item" && auth.args == "\"/data\"" { + Ok(test_harness::Decision::Denied( + test_harness::FilesystemErrorCode::Access, + )) + } else { + Ok(test_harness::Decision::Deferred) + } + })) + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert_eq!(names, ["/"]); + assert_eq!( + gate.recorder().logs(), + [trace( + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories PATH=/data" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_directory_entries_are_omitted_and_logged() { + let fixture = Fixture::new(); + let (entries, gate) = run( + fixture.gate().host_latch(HostLatch::new(|auth| { + if auth.operation == "descriptor.read-directory-entry" + && (auth.args.contains("\"file.txt\"") || auth.args.contains("\"empty\"")) + { + Ok(test_harness::Decision::Denied( + test_harness::FilesystemErrorCode::Access, + )) + } else { + Ok(test_harness::Decision::Deferred) + } + })), + async |accessor, exports, h| read_entries(accessor, exports, h.root).await, + ) + .await; + assert_eq!(entries, (vec!["dir".into(), "link".into()], Ok(()))); + let mut logs = gate.recorder().logs(); + // the order of the entries is up to the host + logs.sort_by(|a, b| a.message.cmp(&b.message)); + assert_eq!( + logs, + [ + trace(format!( + "Denied REASON=access {TYPES}.read-directory FD=/ NAME=empty TYPE=directory" + )), + trace(format!( + "Denied REASON=access {TYPES}.read-directory FD=/ NAME=file.txt TYPE=regular-file" + )), + ] + ); +} + +// --- latch errors --- + +#[tokio::test(flavor = "multi_thread")] +async fn latch_errors_fail_operations_and_are_logged() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().host_latch(HostLatch::error( + &[ + "descriptor.read-via-stream", + "descriptor.write-via-stream", + "descriptor.read-directory", + "descriptor.stat", + ], + "boom", + )), + async |accessor, exports, h| { + let d = descriptor(exports); + let mut results = vec![]; + let (data, result) = d.call_read_via_stream(accessor, h.file, 0).await?; + assert_eq!(read_bytes(accessor, data).await?, b""); + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_write_via_stream(accessor, h.file, data, 0).await?; + results.push(test_harness::resolve(accessor, result).await?); + let (entries, result) = read_entries(accessor, exports, h.dir).await?; + assert!(entries.is_empty(), "{entries:?}"); + results.push(result); + results.push(ok(d.call_stat(accessor, h.file).await?)); + Ok(results) + }, + ) + .await; + let failed = Err(ErrorCode::Other(Some("latch-error: boom".into()))); + assert_eq!(results, [failed.clone(), failed.clone(), failed.clone(), failed]); + let latch_error = |call: &str| error(format!("Latch error CODE=boom {TYPES}.{call}")); + assert_eq!( + gate.recorder().logs(), + [ + latch_error("read-via-stream FD=/file.txt OFFSET=0"), + latch_error("write-via-stream FD=/file.txt OFFSET=0"), + latch_error("read-directory FD=/dir"), + latch_error("stat FD=/file.txt"), + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn directory_entry_latch_error_fails_the_read() { + let fixture = Fixture::new(); + let (entries, gate) = run( + fixture + .gate() + .host_latch(HostLatch::error(&["descriptor.read-directory-entry"], "boom")), + async |accessor, exports, h| read_entries(accessor, exports, h.dir).await, + ) + .await; + assert_eq!( + entries, + ( + vec![], + Err(ErrorCode::Other(Some("latch-error: boom".into()))) + ) + ); + assert_eq!( + gate.recorder().logs(), + [error(format!( + "Latch error CODE=boom {TYPES}.read-directory FD=/dir NAME=nested.txt TYPE=regular-file" + ))] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_observation_fails_the_operation() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .host_latch(HostLatch::defer().fail_observe()) + .build() + .await + .unwrap(); + // a latch error getting the preopens can't be returned, the gate panics + let result = gate + .run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await; + assert!(result.is_err(), "{result:?}"); +} diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml new file mode 100644 index 0000000..dfb1edf --- /dev/null +++ b/components/latch-defer-all/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "latch-defer-all" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/latch-defer-all/README.md b/components/latch-defer-all/README.md new file mode 100644 index 0000000..dbd7286 --- /dev/null +++ b/components/latch-defer-all/README.md @@ -0,0 +1,3 @@ +# `latch-defer-all` + +Filesystem latch that implicitly defers all operations. diff --git a/components/latch-defer-all/src/lib.rs b/components/latch-defer-all/src/lib.rs new file mode 100644 index 0000000..4750b4e --- /dev/null +++ b/components/latch-defer-all/src/lib.rs @@ -0,0 +1,28 @@ +use crate::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, Guest as Latch, Operation, +}; + +struct LatchDenyAll {} + +impl Latch for LatchDenyAll { + #[allow(async_fn_in_trait)] + fn authorize(_operation: Operation) -> Result { + Ok(Decision::Deferred) + } + + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + generate_all +}); + +export!(LatchDenyAll); diff --git a/components/latch-deny-all/Cargo.toml b/components/latch-deny-all/Cargo.toml new file mode 100644 index 0000000..741f659 --- /dev/null +++ b/components/latch-deny-all/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-deny-all" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/latch-deny-all/README.md b/components/latch-deny-all/README.md new file mode 100644 index 0000000..7fdad02 --- /dev/null +++ b/components/latch-deny-all/README.md @@ -0,0 +1,5 @@ +# `latch-deny-all` + +Filesystem latch that implicitly denies all operations. + +Every operation is denied with `access`. Getting the preopens returns no preopens, so no descriptor can be obtained. diff --git a/components/latch-deny-all/src/lib.rs b/components/latch-deny-all/src/lib.rs new file mode 100644 index 0000000..cd9a12d --- /dev/null +++ b/components/latch-deny-all/src/lib.rs @@ -0,0 +1,28 @@ +use crate::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, +}; + +struct LatchDenyAll {} + +impl Latch for LatchDenyAll { + #[allow(async_fn_in_trait)] + fn authorize(_operation: Operation) -> Result { + Ok(Decision::Denied(FilesystemErrorCode::Access)) + } + + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + generate_all +}); + +export!(LatchDenyAll); diff --git a/components/latch-deny-random/Cargo.toml b/components/latch-deny-random/Cargo.toml new file mode 100644 index 0000000..98c4010 --- /dev/null +++ b/components/latch-deny-random/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "latch-deny-random" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-random/README.md b/components/latch-deny-random/README.md new file mode 100644 index 0000000..57c0740 --- /dev/null +++ b/components/latch-deny-random/README.md @@ -0,0 +1,42 @@ +# `latch-deny-random` + +Filesystem latch that randomly denies operations, to prove a component is resilient to failures. + +Every operation is at risk: getting the preopens and each preopen, opening, reading and writing files, reading directories and each directory entry, and every other descriptor operation. A denied operation fails with `access`, or the configured reason, while a denied preopen or directory entry is left out. + +The latch is configured with a wasi:config/store: + +- `probability`: the fraction of operations denied, from `0` to `1`, defaults to `0.1` +- `probability.`: the fraction of the operations of an interface denied, `preopens` or `descriptor`, e.g. `probability.preopens`, defaults to `probability` +- `probability..`: the fraction of an operation denied, named as in the latch WIT, e.g. `probability.descriptor.open-at` or `probability.preopens.get-directories-item`, defaults to the probability of its interface +- `seed`: an unsigned 64 bit integer, the same seed denies the same operations, defaults to a random seed from `wasi:random` +- `reason`: the wasi:filesystem `error-code` denied operations fail with, as written in the WIT, e.g. `io` or `no-entry`, defaults to `access`. Any other value is reported as `other` with the value as the message + +The most specific probability decides, an unknown interface or operation is an invalid config. The preopens are often configured statically by the host, setting their probability to `0` puts only the guest's own operations at risk: + +``` +probability=0.2 +probability.preopens=0 +``` + +The seed is logged as a warning when the latch starts, set it in the config to reproduce a failure. + +``` +Randomly denying wasi:filesystem operations PROBABILITY=0.2 SEED=9383211634937261427 PROBABILITY.preopens=0 +``` + +The decision for an operation depends only on the seed and the number of operations observed before it. Operations another latch denies first still count, so aggregating the latch with others does not change which operations it denies. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs the seed, and an invalid config +- `wasi:config/store@0.2.0-rc.1`: the latch config +- `wasi:random/insecure@0.3.0`: seeds the decisions when no `seed` is configured +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-deny-random/src/lib.rs b/components/latch-deny-random/src/lib.rs new file mode 100644 index 0000000..17ab482 --- /dev/null +++ b/components/latch-deny-random/src/lib.rs @@ -0,0 +1,313 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; + +use filesystem_latch::{Decision, ErrorCode, FilesystemErrorCode, Latch, Operation}; + +const LATCH_NAME: &str = "latch-deny-random"; + +/// The fraction of operations denied when `probability` is not configured. +const DEFAULT_PROBABILITY: f64 = 0.1; + +mod random { + wit_bindgen::generate!({ + path: "../wit", + world: "insecure-random", + generate_all + }); +} + +struct Config { + /// The fraction of operations denied, from 0 to 1, unless the interface or operation has its + /// own probability. + probability: f64, + /// The probability of the operations of an interface, by interface name, e.g. `preopens`. + interfaces: BTreeMap, + /// The probability of an operation, by operation name, e.g. `descriptor.open-at`. + operations: BTreeMap, + /// Seeds the decisions, the same seed denies the same operations. + seed: u64, + reason: FilesystemErrorCode, +} + +impl Config { + /// Load the config, logging why when the config is invalid. + fn load() -> Result { + let config = filesystem_latch::load_config(LATCH_NAME, |config| { + Config::parse(config, || { + random::wasi::random::insecure::get_insecure_random_u64() + }) + })?; + // the seed reproduces the decisions, e.g. a failure the random seed uncovered + let overrides: String = config + .interfaces + .iter() + .chain(&config.operations) + .map(|(name, probability)| format!(" PROBABILITY.{name}={probability}")) + .collect(); + filesystem_latch::warn!( + "Randomly denying wasi:filesystem operations PROBABILITY={} SEED={}{overrides}", + config.probability, + config.seed + ); + Ok(config) + } + + fn parse( + config: Vec<(String, String)>, + random_seed: impl FnOnce() -> u64, + ) -> Result { + let mut probability = DEFAULT_PROBABILITY; + let mut interfaces = BTreeMap::new(); + let mut operations = BTreeMap::new(); + let mut seed = None; + let mut reason = FilesystemErrorCode::Access; + + for (key, value) in config { + let invalid = |err: &str| format!("KEY={key} VALUE={value} ERROR={err}"); + let parse_probability = || { + value + .parse::() + .ok() + .filter(|probability| (0.0..=1.0).contains(probability)) + .ok_or_else(|| invalid("expected a number from 0 to 1")) + }; + match key.as_str() { + "probability" => probability = parse_probability()?, + _ if key.starts_with("probability.") => { + let name = &key["probability.".len()..]; + if filesystem_latch::INTERFACE_NAMES.contains(&name) { + interfaces.insert(name.to_string(), parse_probability()?); + } else if filesystem_latch::OPERATION_NAMES.contains(&name) { + operations.insert(name.to_string(), parse_probability()?); + } else { + return Err(invalid("unknown interface or operation")); + } + } + "seed" => { + seed = Some( + value + .parse::() + .map_err(|_| invalid("expected an unsigned 64 bit integer"))?, + ); + } + "reason" => { + reason = filesystem_latch::reason(value).unwrap_or(FilesystemErrorCode::Access); + } + _ => {} + } + } + + Ok(Config { + probability, + interfaces, + operations, + seed: seed.unwrap_or_else(random_seed), + reason, + }) + } + + /// The probability of the named operation, e.g. `descriptor.open-at`: its own, its interface's, + /// or the default. + fn probability(&self, operation: &str) -> f64 { + let interface = operation.split_once('.').map_or(operation, |(interface, _)| interface); + self.operations + .get(operation) + .or_else(|| self.interfaces.get(interface)) + .copied() + .unwrap_or(self.probability) + } + + /// The decision for the named operation observed after `observed` others. + fn decide(&self, operation: &str, observed: u64) -> Decision { + match sample(self.seed, observed) < self.probability(operation) { + true => Decision::Denied(self.reason.clone()), + false => Decision::Deferred, + } + } +} + +/// A uniformly distributed number from 0 up to 1, the same for the same seed and index. +fn sample(seed: u64, index: u64) -> f64 { + // splitmix64, each index is a step of the sequence the seed starts + let mut z = seed.wrapping_add(index.wrapping_add(1).wrapping_mul(0x9e37_79b9_7f4a_7c15)); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + // the top 53 bits, the precision of an f64 + (z >> 11) as f64 / (1u64 << 53) as f64 +} + +thread_local! { + /// `None` until the config is loaded. + static CONFIG: RefCell>> = const { RefCell::new(None) }; + + /// The number of operations observed. + static OBSERVED: RefCell = const { RefCell::new(0) }; +} + +struct RandomLatch {} + +impl Latch for RandomLatch { + fn authorize(operation: Operation) -> Result { + // the decision only depends on how many operations were observed, authorizing does not + // change it, so an operation another latch denied first does not shift the decisions + CONFIG.with_borrow_mut(|config| match config.get_or_insert_with(Config::load) { + Ok(config) => { + let observed = OBSERVED.with_borrow(|observed| *observed); + Ok(config.decide(filesystem_latch::operation_name(&operation), observed)) + } + Err(err) => Err(err.clone()), + }) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + OBSERVED.with_borrow_mut(|observed| *observed += 1); + Ok(()) + } +} + +filesystem_latch::export!(RandomLatch with_types_in filesystem_latch::bindings); + +#[cfg(test)] +mod tests { + use super::*; + + fn config(entries: &[(&str, &str)]) -> Result { + let entries = entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + Config::parse(entries, || 42) + } + + fn denied(config: &Config, operations: u64) -> Vec { + (0..operations) + .filter(|observed| matches!(config.decide("descriptor.stat", *observed), Decision::Denied(_))) + .collect() + } + + #[test] + fn defaults() { + let config = config(&[]).expect("valid config"); + assert_eq!(config.probability, DEFAULT_PROBABILITY); + assert_eq!(config.seed, 42, "seeded randomly"); + assert!(matches!(config.reason, FilesystemErrorCode::Access)); + } + + #[test] + fn denies_about_the_configured_fraction() { + for probability in [0.1, 0.5, 0.9] { + let config = config(&[("probability", &probability.to_string()), ("seed", "7")]) + .expect("valid config"); + let fraction = denied(&config, 10_000).len() as f64 / 10_000.0; + assert!( + (fraction - probability).abs() < 0.02, + "probability={probability} fraction={fraction}" + ); + } + } + + #[test] + fn never_or_always_denies() { + let never = config(&[("probability", "0")]).expect("valid config"); + assert!(denied(&never, 1_000).is_empty()); + let always = config(&[("probability", "1")]).expect("valid config"); + assert_eq!(denied(&always, 1_000).len(), 1_000); + } + + #[test] + fn same_seed_same_decisions() { + let first = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let again = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let other = config(&[("probability", "0.5"), ("seed", "2")]).expect("valid config"); + assert_eq!(denied(&first, 100), denied(&again, 100)); + assert_ne!(denied(&first, 100), denied(&other, 100)); + } + + #[test] + fn denies_with_configured_reason() { + let config = config(&[("probability", "1"), ("reason", "io")]).expect("valid config"); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(FilesystemErrorCode::Io) + )); + } + + #[test] + fn unknown_reason_is_other() { + let config = + config(&[("probability", "1"), ("reason", "chaos-monkey")]).expect("valid config"); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(FilesystemErrorCode::Other(Some(ref message))) if message == "chaos-monkey" + )); + } + + #[test] + fn probability_varies_by_interface_and_operation() { + let varied = config(&[ + ("probability", "0.2"), + ("probability.preopens", "0"), + ("probability.descriptor", "0.5"), + ("probability.descriptor.open-at", "1"), + ("probability.preopens.get-directories-item", "0.3"), + ]) + .expect("valid config"); + assert_eq!(varied.probability("preopens.get-directories"), 0.0); + assert_eq!(varied.probability("preopens.get-directories-item"), 0.3); + assert_eq!(varied.probability("descriptor.stat"), 0.5); + assert_eq!(varied.probability("descriptor.open-at"), 1.0); + + let default = config(&[("probability", "0.2")]).expect("valid config"); + assert_eq!(default.probability("preopens.get-directories"), 0.2); + assert_eq!(default.probability("descriptor.open-at"), 0.2); + } + + #[test] + fn operation_probability_decides() { + let config = config(&[("probability", "1"), ("probability.preopens", "0")]) + .expect("valid config"); + assert!(matches!( + config.decide("preopens.get-directories", 0), + Decision::Deferred + )); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(_) + )); + } + + #[test] + fn invalid_config() { + for (key, value) in [ + ("probability", "1.5"), + ("probability", "-0.1"), + ("probability", "often"), + ("seed", "-1"), + ("seed", "random"), + ("probability.preopens", "often"), + ("probability.descriptor.open-at", "2"), + ] { + let err = config(&[(key, value)]).err().expect("invalid config"); + assert!( + err.starts_with(&format!("KEY={key} VALUE={value} ERROR=")), + "{err}" + ); + } + } + + #[test] + fn unknown_interface_or_operation_is_invalid() { + for key in [ + "probability.sockets", + "probability.descriptor.open", + "probability.open-at", + "probability.", + ] { + assert_eq!( + config(&[(key, "0")]).err().expect("invalid config"), + format!("KEY={key} VALUE=0 ERROR=unknown interface or operation") + ); + } + } +} diff --git a/components/latch-deny-random/tests/latch.rs b/components/latch-deny-random/tests/latch.rs new file mode 100644 index 0000000..9d48c5b --- /dev/null +++ b/components/latch-deny-random/tests/latch.rs @@ -0,0 +1,178 @@ +//! Tests for `latch-deny-random`, as the gate's latch. +//! +//! Each test preopens an empty directory as `/`, and gets the preopens repeatedly. Getting the +//! preopens and the preopen are each authorized, a denial of either returns no preopens. + +use tempfile::TempDir; +use test_harness::{ErrorCode, Harness, Level, LogEntry, Perms, TestSubject}; + +const LATCH: &str = "latch-deny-random"; + +/// A gate with the latch, configured with the entries. +async fn gate(dir: &TempDir, config: &[(&str, &str)]) -> wasmtime::Result { + let mut harness = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch(LATCH); + for (key, value) in config { + harness = harness.config(key, value); + } + harness.build().await +} + +/// Get the preopens, whether each get was denied. +async fn gets(gate: &mut TestSubject, count: usize) -> wasmtime::Result> { + gate.run(async move |accessor, exports| { + let mut denied = vec![]; + for _ in 0..count { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + denied.push(dirs.is_empty()); + } + Ok(denied) + }) + .await +} + +/// The messages the latch logged. +fn latch_logs(gate: &TestSubject) -> Vec { + gate.recorder() + .logs() + .into_iter() + .filter(|entry| entry.context == "componentized-latch") + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn always_denies() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "1"), ("seed", "7")]).await?; + assert_eq!(gets(&mut gate, 3).await?, vec![true, true, true]); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::warn( + "componentized-latch", + "Randomly denying wasi:filesystem operations PROBABILITY=1 SEED=7" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn denies_with_the_configured_reason() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "1"), ("reason", "io")]).await?; + gets(&mut gate, 1).await?; + assert_eq!( + gate.recorder() + .logs() + .into_iter() + .filter(|entry| entry.context == "componentized-gate") + .collect::>(), + vec![LogEntry::warn( + "componentized-gate", + "Denied REASON=io OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn never_denies() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "0")]).await?; + assert_eq!(gets(&mut gate, 3).await?, vec![false, false, false]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_seed_denies_the_same_operations() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let config = [("probability", "0.5"), ("seed", "1234")]; + let first = gets(&mut gate(&dir, &config).await?, 32).await?; + let again = gets(&mut gate(&dir, &config).await?, 32).await?; + assert_eq!(first, again); + // some of each, two coin flips for each of 32 gets + assert!(first.contains(&true) && first.contains(&false), "{first:?}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn random_seed_is_logged() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut logged = vec![]; + for _ in 0..2 { + let mut gate = gate(&dir, &[]).await?; + gets(&mut gate, 1).await?; + let logs = latch_logs(&gate); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, Level::Warn); + let seed = logs[0] + .message + .strip_prefix("Randomly denying wasi:filesystem operations PROBABILITY=0.1 SEED=") + .expect("seed is logged") + .parse::()?; + logged.push(seed); + } + // each instance draws its own seed + assert_ne!(logged[0], logged[1]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_config_is_logged() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "often")]).await?; + // a latch error getting the preopens can't be returned, the gate traps + assert!(gets(&mut gate, 1).await.is_err()); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::critical( + "componentized-latch", + "Invalid config LATCH=latch-deny-random KEY=probability VALUE=often ERROR=expected a number from 0 to 1" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn probability_varies_by_operation() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate( + &dir, + &[ + ("probability", "1"), + ("probability.preopens", "0"), + ("seed", "7"), + ], + ) + .await?; + let (preopens, stat) = gate + .run(async |accessor, exports| { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + let count = dirs.len(); + let (root, _) = dirs.into_iter().next().expect("a preopen"); + let stat = exports + .wasi_filesystem_types() + .descriptor() + .call_stat(accessor, root) + .await?; + Ok((count, stat.map(|_| ()))) + }) + .await?; + // the preopens are never denied, every other operation is + assert_eq!(preopens, 1); + assert_eq!(stat, Err(ErrorCode::Access)); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::warn( + "componentized-latch", + "Randomly denying wasi:filesystem operations PROBABILITY=1 SEED=7 PROBABILITY.preopens=0" + )] + ); + Ok(()) +} diff --git a/components/latch-deny-symlink-at/Cargo.toml b/components/latch-deny-symlink-at/Cargo.toml new file mode 100644 index 0000000..0485920 --- /dev/null +++ b/components/latch-deny-symlink-at/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-deny-symlink-at" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-symlink-at/README.md b/components/latch-deny-symlink-at/README.md new file mode 100644 index 0000000..29cf432 --- /dev/null +++ b/components/latch-deny-symlink-at/README.md @@ -0,0 +1,18 @@ +# `latch-deny-symlink-at` + +Filesystem latch that denies creating symlinks. + +`symlink-at` fails with `not-permitted`, every other operation is deferred. Existing symlinks may still be read, followed, renamed and removed. + +Path based latches, like `latch-path-glob`, match the paths an operation is given, not the paths symlinks resolve to. A symlink inside a preopen can reach a path a pattern protects through a path the pattern does not match. Symlinks outside the preopen are not followed by the host. When the preopened directories are known to not contain symlinks, this latch keeps it that way, so the guest can not create a symlink around the patterns. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-deny-symlink-at/src/lib.rs b/components/latch-deny-symlink-at/src/lib.rs new file mode 100644 index 0000000..0b3f1d6 --- /dev/null +++ b/components/latch-deny-symlink-at/src/lib.rs @@ -0,0 +1,33 @@ +use crate::exports::componentized::filesystem::latch::{ + Decision, DescriptorOperation, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, +}; + +struct DenySymlinkAtLatch {} + +impl Latch for DenySymlinkAtLatch { + fn authorize(operation: Operation) -> Result { + match operation { + Operation::Descriptor(DescriptorOperation::SymlinkAt(_)) => { + Ok(Decision::Denied(FilesystemErrorCode::NotPermitted)) + } + _ => Ok(Decision::Deferred), + } + } + + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + merge_structurally_equal_types: true, + generate_all +}); + +export!(DenySymlinkAtLatch); diff --git a/components/latch-deny-symlink-at/tests/latch.rs b/components/latch-deny-symlink-at/tests/latch.rs new file mode 100644 index 0000000..e495010 --- /dev/null +++ b/components/latch-deny-symlink-at/tests/latch.rs @@ -0,0 +1,172 @@ +//! Tests for `latch-deny-symlink-at`, as the gate's latch. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`, so any denial comes from the +//! latch rather than the host: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{GuestDescriptor, NewTimestamp}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, LogEntry, OpenFlags, + PathFlags, Perms, TestSubject, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +fn fixture() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("file.txt"), "hello").unwrap(); + symlink("file.txt", dir.path().join("link")).unwrap(); + dir +} + +async fn gate(dir: &TempDir) -> TestSubject { + Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-deny-symlink-at") + .build() + .await + .expect("gate") +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn root(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + Ok(dirs.into_iter().next().expect("a preopen").0) +} + +async fn open( + accessor: &Accessor, + exports: &Exports, + root: ResourceAny, + path_flags: PathFlags, + path: &str, +) -> wasmtime::Result> { + let result = descriptor(exports) + .call_open_at( + accessor, + root, + path_flags, + path.into(), + OpenFlags::empty(), + DescriptorFlags::READ, + ) + .await?; + Ok(result.map(|_| ())) +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn creating_symlinks_is_denied() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let result = gate + .run(async |accessor, exports| { + let root = root(accessor, exports).await?; + descriptor(exports) + .call_symlink_at(accessor, root, "file.txt".into(), "new-link".into()) + .await + }) + .await?; + assert_eq!(result, Err(ErrorCode::NotPermitted)); + assert!(fs::symlink_metadata(dir.path().join("new-link")).is_err()); + assert_eq!( + gate.recorder().logs(), + [LogEntry::warn( + "componentized-gate", + "Denied REASON=not-permitted OPERATION=wasi:filesystem/types#descriptor.symlink-at FD=/ OLD-PATH=file.txt NEW-PATH=new-link" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn existing_symlinks_are_followed() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let (opened, stat, hash, times) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let follow = PathFlags::SYMLINK_FOLLOW; + Ok(( + open(accessor, exports, root, follow, "link").await?, + d.call_stat_at(accessor, root, follow, "link".into()) + .await? + .map(|stat| stat.type_), + ok(d.call_metadata_hash_at(accessor, root, follow, "link".into()) + .await?), + d.call_set_times_at( + accessor, + root, + follow, + "link".into(), + NewTimestamp::Now, + NewTimestamp::Now, + ) + .await?, + )) + }) + .await?; + assert_eq!(opened, Ok(())); + assert_eq!(stat, Ok(DescriptorType::RegularFile)); + assert_eq!(hash, Ok(())); + assert_eq!(times, Ok(())); + assert_eq!(gate.recorder().logs(), []); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn symlinks_are_read_without_following() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let (stat, target) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + Ok(( + d.call_stat_at(accessor, root, PathFlags::empty(), "link".into()) + .await? + .map(|stat| stat.type_), + d.call_readlink_at(accessor, root, "link".into()).await?, + )) + }) + .await?; + assert_eq!(stat, Ok(DescriptorType::SymbolicLink)); + assert_eq!(target, Ok("file.txt".into())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn symlinks_can_be_removed() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let result = gate + .run(async |accessor, exports| { + let root = root(accessor, exports).await?; + descriptor(exports) + .call_unlink_file_at(accessor, root, "link".into()) + .await + }) + .await?; + assert_eq!(result, Ok(())); + assert!(fs::symlink_metadata(dir.path().join("link")).is_err()); + Ok(()) +} diff --git a/components/latch-n2/Cargo.toml b/components/latch-n2/Cargo.toml new file mode 100644 index 0000000..692aa71 --- /dev/null +++ b/components/latch-n2/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n2" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n2/README.md b/components/latch-n2/README.md new file mode 100644 index 0000000..471bb99 --- /dev/null +++ b/components/latch-n2/README.md @@ -0,0 +1,15 @@ +# `latch-n2` + +Filesystem latch that aggregates two other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n2/src/lib.rs b/components/latch-n2/src/lib.rs new file mode 100644 index 0000000..b791b15 --- /dev/null +++ b/components/latch-n2/src/lib.rs @@ -0,0 +1,22 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1}; + +struct LatchN2 {} + +impl Latch for LatchN2 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![latch0::observe_decision, latch1::observe_decision]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN2 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n3/Cargo.toml b/components/latch-n3/Cargo.toml new file mode 100644 index 0000000..b9a62d2 --- /dev/null +++ b/components/latch-n3/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n3" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n3/README.md b/components/latch-n3/README.md new file mode 100644 index 0000000..b22ab4e --- /dev/null +++ b/components/latch-n3/README.md @@ -0,0 +1,16 @@ +# `latch-n3` + +Filesystem latch that aggregates three other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n3/src/lib.rs b/components/latch-n3/src/lib.rs new file mode 100644 index 0000000..b105445 --- /dev/null +++ b/components/latch-n3/src/lib.rs @@ -0,0 +1,26 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2}; + +struct LatchN3 {} + +impl Latch for LatchN3 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize, latch2::authorize]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN3 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n4/Cargo.toml b/components/latch-n4/Cargo.toml new file mode 100644 index 0000000..e18bd53 --- /dev/null +++ b/components/latch-n4/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n4" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n4/README.md b/components/latch-n4/README.md new file mode 100644 index 0000000..3475784 --- /dev/null +++ b/components/latch-n4/README.md @@ -0,0 +1,17 @@ +# `latch-n4` + +Fileystem latch that aggregates four other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch3: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n4/src/lib.rs b/components/latch-n4/src/lib.rs new file mode 100644 index 0000000..6c29d1f --- /dev/null +++ b/components/latch-n4/src/lib.rs @@ -0,0 +1,32 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2, latch3}; + +struct LatchN4 {} + +impl Latch for LatchN4 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + ]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN4 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n5/Cargo.toml b/components/latch-n5/Cargo.toml new file mode 100644 index 0000000..f467e35 --- /dev/null +++ b/components/latch-n5/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n5" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n5/README.md b/components/latch-n5/README.md new file mode 100644 index 0000000..08d7d08 --- /dev/null +++ b/components/latch-n5/README.md @@ -0,0 +1,18 @@ +# `latch-n5` + +Filesystem latch that aggregates five other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch3: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch4: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.1.1-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n5/src/lib.rs b/components/latch-n5/src/lib.rs new file mode 100644 index 0000000..77d2c28 --- /dev/null +++ b/components/latch-n5/src/lib.rs @@ -0,0 +1,34 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2, latch3, latch4}; + +struct LatchN5 {} + +impl Latch for LatchN5 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + latch4::authorize, + ]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + latch4::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN5 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-path-glob/Cargo.toml b/components/latch-path-glob/Cargo.toml new file mode 100644 index 0000000..7bbedf0 --- /dev/null +++ b/components/latch-path-glob/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "latch-path-glob" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch = { workspace = true } +glob = "0.3" +wit-bindgen = { workspace = true } diff --git a/components/latch-path-glob/README.md b/components/latch-path-glob/README.md new file mode 100644 index 0000000..ffc8a39 --- /dev/null +++ b/components/latch-path-glob/README.md @@ -0,0 +1,83 @@ +# `latch-path-glob` + +Filesystem latch that uses glob patterns to defer or deny operations on a path. + +The patterns are defined in a `wasi:config/store`. Keys starting with `deny` are parsed as globs with matching paths being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `defer` are parsed as globs with matching paths deferring the decision. + +> [!CAUTION] +> Symlinks and overlapping preopen directories may provide alternate paths to the same file either matching or bypassing an expected pattern. Patterns are applied to paths as provided to calls on the `wasi:filesystem` interface. + +A single `*` matches within a path segment, between `/` separators, while `**` spans segments. + +``` +/data/* + +/data/file.txt -> MATCHES +/data/dir/file.txt -> NO MATCH +/other/file.txt -> NO MATCH +``` + +``` +/data/** + +/data/file.txt -> MATCHES +/data/dir/file.txt -> MATCHES +/other/file.txt -> NO MATCH +``` + +Operations are evaluated against the patterns with the base path for the operation joined with a relative path, if any. For example, the descriptor `open-at` operation joins the descriptor's path with the argument's path, and the resulting path is matched against the patterns. For operations with multiple paths, like `rename-at`, each path is decided individually and any denied path denies the operation. + +When several patterns match a path, the most specific pattern decides, regardless of whether it is a `deny` or `defer` pattern or the order the keys are defined in. Patterns are compared segment by segment starting from the root (`data` in `/data/file.txt`), the first segment that differs decides. Within a segment, a literal (`file.txt`) is more specific than a partial wildcard (`*.txt`), which is more specific than `*`, which is more specific than `**`. Between two partial wildcards, the one with more literal characters is more specific. When the matching patterns are equally specific, `deny` wins. + +``` +deny-1=/** +defer-1=/data/** +deny-2=/data/secret.txt + +/etc/passwd -> DENIED (deny-1) +/data/file.txt -> DEFERRED (defer-1) +/data/secret.txt -> DENIED (deny-2) +``` + +Whether the host filesystem is case sensitive is not known to the latch, so matching is case insensitive by default: `deny=/secret/**` also denies `/SECRET/file.txt`. Set `case-sensitive=true` when the host filesystem is known to be case sensitive. With case insensitive matching a `defer` pattern also matches other casings, which on a case sensitive filesystem are different paths, e.g. `default=deny` with `defer=/data/**` defers `/DATA/file.txt`. + +Paths that do not match any pattern use the `default` decision, either `defer` (the default) or `deny`. + +Denied operations fail with `access` unless the `reason` key is set. Any `wasi:filesystem` error code is supported, e.g. `no-entry` to hide denied paths or `not-permitted`, any other value is reported as `other` with the value as the message. The reason applies to both matching `deny` patterns and a `deny` default, and does not change the default decision. + +``` +default=deny +reason=no-entry +defer=/data/** + +/data/file.txt -> DEFERRED +/etc/passwd -> DENIED (no-entry) +``` + +Keys starting with `readonly` are parsed as globs for paths that may be read but not changed. They are only evaluated when the operation would otherwise be deferred, by a matching `defer` pattern or a `defer` default, and when the operation would change the filesystem. A matching operation is denied with `read-only`, regardless of the `reason` key. Operations that change the filesystem are writing, appending, syncing, resizing, setting times, creating directories, linking, renaming, removing, unlinking, and opening with flags that allow changes (`create`, `exclusive` or `truncate` open flags, or `write`, `mutate-directory` or sync descriptor flags). Operations on a descriptor without a path argument, like writing to a file, are evaluated against the descriptor's path and are only checked against `readonly` patterns, the `deny` and `defer` patterns were already evaluated when the descriptor was opened. + +``` +reason=no-entry +deny=/data/secret/** +readonly=/data/** + +open /data/file.txt for reading -> DEFERRED +open /data/file.txt for writing -> DENIED (read-only) +open /data/secret/file.txt for reading -> DENIED (no-entry) +open /data/secret/file.txt for writing -> DENIED (no-entry) +``` + +If the config is invalid (a pattern that does not parse as a glob, or an unknown `default` or `case-sensitive` value), the cause is logged when the config is loaded and every operation fails with an `invalid-config` latch error. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:config/store@0.2.0-rc.1`: the glob patterns +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs new file mode 100644 index 0000000..ca707ea --- /dev/null +++ b/components/latch-path-glob/src/lib.rs @@ -0,0 +1,789 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use glob::{MatchOptions, Pattern}; + +use filesystem_latch::{ + Decision, DescriptorFlags, DescriptorOperation, ErrorCode, FilesystemErrorCode, Latch, OpenFlags, + Operation, PreopensOperation, +}; + +const LATCH_NAME: &str = "latch-path-glob"; + +struct PathGlobLatch {} + +/// Whether the host filesystem is case sensitive is unknown, matching is case insensitive unless +/// the config turns case sensitivity on. +fn match_options(case_sensitive: bool) -> MatchOptions { + MatchOptions { + case_sensitive, + // a single `*` must not match across path segments, `**` spans them + require_literal_separator: true, + require_literal_leading_dot: false, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Action { + // ordered so that deny sorts first when patterns are equally specific + Deny, + Defer, +} + +struct Rule { + pattern: Pattern, + specificity: Vec<(u8, usize)>, + action: Action, +} + +impl Rule { + fn new(pattern: &str, action: Action) -> Result { + Ok(Rule { + pattern: Pattern::new(pattern).map_err(|err| err.to_string())?, + specificity: specificity(pattern), + action, + }) + } +} + +/// Rank each segment of a pattern, starting with the segment closest to the root. +/// +/// Rankings compare segment by segment, the first segment that differs decides which pattern is +/// more specific. Within a segment, a literal beats a partial wildcard (more literal characters +/// win), which beats `*`, which beats `**`. +fn specificity(pattern: &str) -> Vec<(u8, usize)> { + pattern + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| match segment { + "**" => (0, 0), + "*" => (2, 0), + _ if segment.contains(['*', '?', '[']) => (3, literal_chars(segment)), + _ => (4, segment.len()), + }) + .collect() +} + +/// A pattern that has no more segments only matches when the path also has no more segments, +/// which is more specific than `**` but less specific than any other segment. +const END_OF_PATTERN: (u8, usize) = (1, 0); + +fn literal_chars(segment: &str) -> usize { + let mut count = 0; + let mut in_class = false; + for c in segment.chars() { + match c { + '[' => in_class = true, + ']' => in_class = false, + '*' | '?' => {} + _ if !in_class => count += 1, + _ => {} + } + } + count +} + +fn compare_specificity(a: &[(u8, usize)], b: &[(u8, usize)]) -> std::cmp::Ordering { + (0..a.len().max(b.len())) + .map(|i| { + let a = a.get(i).unwrap_or(&END_OF_PATTERN); + let b = b.get(i).unwrap_or(&END_OF_PATTERN); + a.cmp(b) + }) + .find(|ordering| ordering.is_ne()) + .unwrap_or(std::cmp::Ordering::Equal) +} + +/// Whether an operation only reads the filesystem, or may change it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Access { + Read, + Mutate, +} + +/// Whether opening a file with the flags would let the descriptor change the filesystem. +fn open_access(open_flags: OpenFlags, flags: DescriptorFlags) -> Access { + let mutating_open = OpenFlags::CREATE | OpenFlags::EXCLUSIVE | OpenFlags::TRUNCATE; + let mutating_flags = DescriptorFlags::WRITE + | DescriptorFlags::FILE_INTEGRITY_SYNC + | DescriptorFlags::DATA_INTEGRITY_SYNC + | DescriptorFlags::REQUESTED_WRITE_SYNC + | DescriptorFlags::MUTATE_DIRECTORY; + match open_flags.intersects(mutating_open) || flags.intersects(mutating_flags) { + true => Access::Mutate, + false => Access::Read, + } +} + +struct Patterns { + /// most specific first + rules: Vec, + /// paths that may be read but not changed, when the rules defer + readonly: Vec, + reason: FilesystemErrorCode, + default: Action, + match_options: MatchOptions, +} + +impl Patterns { + /// Load the patterns from config, logging why when the config is invalid. + fn load() -> Result { + filesystem_latch::load_config(LATCH_NAME, Patterns::parse) + } + + fn parse(config: Vec<(String, String)>) -> Result { + let mut rules: Vec = vec![]; + let mut readonly: Vec = vec![]; + let mut reason = FilesystemErrorCode::Access; + let mut default = Action::Defer; + let mut case_sensitive = false; + + for (key, value) in config { + let invalid = |err: String| format!("KEY={key} VALUE={value} ERROR={err}"); + if key.starts_with("deny") { + rules.push(Rule::new(&value, Action::Deny).map_err(invalid)?); + } else if key.starts_with("defer") { + rules.push(Rule::new(&value, Action::Defer).map_err(invalid)?); + } else if key.starts_with("readonly") { + readonly.push(Pattern::new(&value).map_err(|err| invalid(err.to_string()))?); + } else if key == "reason" { + reason = filesystem_latch::reason(value).unwrap_or(FilesystemErrorCode::Access); + } else if key == "default" { + default = match value.as_str() { + "deny" => Action::Deny, + "defer" => Action::Defer, + _ => return Err(invalid("expected 'deny' or 'defer'".to_string())), + } + } else if key == "case-sensitive" { + case_sensitive = match value.as_str() { + "true" => true, + "false" => false, + _ => return Err(invalid("expected 'true' or 'false'".to_string())), + } + } + } + + rules.sort_by(|a, b| { + compare_specificity(&b.specificity, &a.specificity).then(a.action.cmp(&b.action)) + }); + + Ok(Patterns { + rules, + readonly, + reason, + default, + match_options: match_options(case_sensitive), + }) + } + + /// Decide an operation on the base path, or on the paths relative to it. + fn authorize(path: String, paths: Vec, access: Access) -> Result { + Patterns::with(|patterns| patterns.decide(&path, &paths, access)) + } + + /// Decide an operation that changes the descriptor at the path, without a path argument. + /// + /// The deny and defer patterns are not evaluated, only the readonly patterns: the descriptor was + /// already deferred when it was opened or preopened, it can't be obtained otherwise. + fn authorize_mutation(path: String) -> Result { + Patterns::with(|patterns| patterns.readonly_decision(&[PathBuf::from(path)])) + } + + fn with(decide: impl FnOnce(&Patterns) -> Decision) -> Result { + // config is loaded once, an invalid config fails every authorization + STATE.with_borrow_mut(|state| match state.get_or_insert_with(Patterns::load) { + Ok(patterns) => Ok(decide(patterns)), + Err(err) => Err(err.clone()), + }) + } + + /// Deny when the base path, or any of the paths relative to it, is denied. + /// + /// Each path is decided by the most specific matching pattern, or the default when no pattern + /// matches. An operation that is otherwise deferred and changes the filesystem is denied with + /// `read-only` when any of its paths matches a readonly pattern. + fn decide(&self, path: &str, paths: &[String], access: Access) -> Decision { + let base = Path::new(path); + let paths: Vec = match paths { + [] => vec![base.to_path_buf()], + paths => paths.iter().map(|p| base.join(p)).collect(), + }; + if paths.iter().any(|p| self.action(p) == Action::Deny) { + return Decision::Denied(self.reason.clone()); + } + match access { + Access::Read => Decision::Deferred, + Access::Mutate => self.readonly_decision(&paths), + } + } + + /// Deny with `read-only` when any of the paths matches a readonly pattern, regardless of the + /// configured reason. + fn readonly_decision(&self, paths: &[PathBuf]) -> Decision { + let readonly = paths.iter().any(|path| { + self.readonly + .iter() + .any(|pattern| pattern.matches_path_with(path, self.match_options)) + }); + match readonly { + true => Decision::Denied(FilesystemErrorCode::ReadOnly), + false => Decision::Deferred, + } + } + + fn action(&self, path: &Path) -> Action { + self.authorize_path(path).unwrap_or(self.default) + } + + /// The action of the most specific matching pattern. + fn authorize_path(&self, path: &Path) -> Option { + self.rules + .iter() + .find(|rule| rule.pattern.matches_path_with(path, self.match_options)) + .map(|rule| rule.action) + } +} + +thread_local! { + /// `None` until the config is loaded. Components are single threaded, this is the latch's only + /// state. + static STATE: RefCell>> = const { RefCell::new(None) }; +} + +impl Latch for PathGlobLatch { + fn authorize(operation: Operation) -> Result { + use Access::{Mutate, Read}; + + match operation { + Operation::Preopens(preopens_operation) => match preopens_operation { + PreopensOperation::GetDirectories => Ok(Decision::Deferred), + PreopensOperation::GetDirectoriesItem((_, path)) => { + Patterns::authorize(path, vec![], Read) + } + }, + Operation::Descriptor(descriptor_operation) => match descriptor_operation { + DescriptorOperation::ReadViaStream(_) => Ok(Decision::Deferred), + DescriptorOperation::WriteViaStream((_, path, _)) => { + Patterns::authorize_mutation(path) + } + DescriptorOperation::AppendViaStream((_, path)) => { + Patterns::authorize_mutation(path) + } + DescriptorOperation::Advise(_) => Ok(Decision::Deferred), + DescriptorOperation::SyncData((_, path)) => Patterns::authorize_mutation(path), + DescriptorOperation::GetFlags(_) => Ok(Decision::Deferred), + DescriptorOperation::GetType(_) => Ok(Decision::Deferred), + DescriptorOperation::SetSize((_, path, _)) => Patterns::authorize_mutation(path), + DescriptorOperation::SetTimes((_, path, _)) => Patterns::authorize_mutation(path), + DescriptorOperation::ReadDirectory(_) => Ok(Decision::Deferred), + DescriptorOperation::ReadDirectoryEntry((_, path, entry)) => { + Patterns::authorize(path, vec![entry.name], Read) + } + DescriptorOperation::Sync((_, path)) => Patterns::authorize_mutation(path), + DescriptorOperation::CreateDirectoryAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Mutate) + } + DescriptorOperation::Stat(_) => Ok(Decision::Deferred), + DescriptorOperation::StatAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Read) + } + DescriptorOperation::SetTimesAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Mutate) + } + DescriptorOperation::LinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) + } + DescriptorOperation::OpenAt((_, path, args)) => { + let access = open_access(args.open_flags, args.flags); + Patterns::authorize(path, vec![args.path], access) + } + DescriptorOperation::ReadlinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Read) + } + DescriptorOperation::RemoveDirectoryAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Mutate) + } + DescriptorOperation::RenameAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) + } + DescriptorOperation::SymlinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) + } + DescriptorOperation::UnlinkFileAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Mutate) + } + DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), + DescriptorOperation::MetadataHashAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path], Read) + } + }, + } + } + + #[doc = " Observe the final decision for an operation, after `authorize` returned a decision."] + #[doc = ""] + #[doc = " Called for both deferred and denied operations, including operations this latch was not"] + #[doc = " asked to authorize because an aggregated latch denied them first. The final decision is"] + #[doc = " what the gate enforces, so this is where a latch updates any state it keeps, for example"] + #[doc = " remembering peers the caller is allowed to exchange traffic with. Not called when"] + #[doc = " `authorize` returned an error."] + #[doc = ""] + #[doc = " An error fails the operation, since the latch could not act on the decision."] + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +filesystem_latch::export!(PathGlobLatch with_types_in filesystem_latch::bindings); + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(config: &[(&str, &str)]) -> Result { + Patterns::parse( + config + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) + } + + fn patterns(config: &[(&str, &str)]) -> Patterns { + parse(config).expect("valid config") + } + + fn decision(patterns: &Patterns, path: &str, paths: &[&str]) -> String { + decision_for(patterns, path, paths, Access::Read) + } + + fn mutation(patterns: &Patterns, path: &str, paths: &[&str]) -> String { + decision_for(patterns, path, paths, Access::Mutate) + } + + fn decision_for(patterns: &Patterns, path: &str, paths: &[&str], access: Access) -> String { + let paths: Vec = paths.iter().map(|p| p.to_string()).collect(); + format!("{:?}", patterns.decide(path, &paths, access)) + } + + fn denied(reason: FilesystemErrorCode) -> String { + format!("{:?}", Decision::Denied(reason)) + } + + fn deferred() -> String { + format!("{:?}", Decision::Deferred) + } + + #[test] + fn matching_base_path_is_denied() { + let patterns = patterns(&[("deny", "/secret")]); + assert_eq!( + decision(&patterns, "/secret", &[]), + denied(FilesystemErrorCode::Access) + ); + assert_eq!(decision(&patterns, "/", &[]), deferred()); + } + + #[test] + fn relative_paths_are_joined_to_the_base_path() { + let patterns = patterns(&[("deny", "/data/*.key")]); + assert_eq!( + decision(&patterns, "/data", &["id.key"]), + denied(FilesystemErrorCode::Access) + ); + assert_eq!(decision(&patterns, "/data", &["id.pub"]), deferred()); + // only the joined paths are matched, not the base path alone + assert_eq!(decision(&patterns, "/data/id.key", &["x"]), deferred()); + } + + #[test] + fn any_matching_path_denies() { + let patterns = patterns(&[("deny", "/data/*.key")]); + assert_eq!( + decision(&patterns, "/data", &["id.pub", "id.key"]), + denied(FilesystemErrorCode::Access) + ); + } + + #[test] + fn multiple_deny_keys() { + let patterns = patterns(&[("deny-1", "/a"), ("deny-2", "/b")]); + let access = denied(FilesystemErrorCode::Access); + assert_eq!(decision(&patterns, "/a", &[]), access); + assert_eq!(decision(&patterns, "/b", &[]), access); + assert_eq!(decision(&patterns, "/c", &[]), deferred()); + } + + #[test] + fn unrelated_keys_are_ignored() { + let patterns = patterns(&[("other", "/a")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn denies_with_configured_reason() { + for (value, expected) in [ + ("access", FilesystemErrorCode::Access), + ("read-only", FilesystemErrorCode::ReadOnly), + ("other", FilesystemErrorCode::Other(None)), + ("", FilesystemErrorCode::Access), + ( + "custom-reason", + FilesystemErrorCode::Other(Some("custom-reason".to_string())), + ), + ] { + let patterns = patterns(&[("reason", value), ("deny", "/a")]); + assert_eq!( + decision(&patterns, "/a", &[]), + denied(expected), + "reason={value}" + ); + } + } + + fn action(patterns: &Patterns, path: &str) -> Option { + patterns.authorize_path(Path::new(path)) + } + + #[test] + fn single_star_matches_within_a_segment() { + let patterns = patterns(&[("deny", "/data/*")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/dir/file.txt"), None); + assert_eq!(action(&patterns, "/other/file.txt"), None); + } + + #[test] + fn double_star_spans_segments() { + let patterns = patterns(&[("deny", "/data/**")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/dir/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/other/file.txt"), None); + } + + #[test] + fn matching_is_case_insensitive_by_default() { + let patterns = patterns(&[("deny", "/Data/*.TXT")]); + assert_eq!(action(&patterns, "/Data/file.TXT"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/DATA/FILE.txt"), Some(Action::Deny)); + } + + #[test] + fn matching_is_case_sensitive_when_configured() { + for config in [ + [("case-sensitive", "true"), ("deny", "/Data/*")], + [("deny", "/Data/*"), ("case-sensitive", "true")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/Data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/file.txt"), None); + } + let patterns = patterns(&[("case-sensitive", "false"), ("deny", "/Data/*")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + } + + #[test] + fn unknown_case_sensitive_is_invalid() { + assert_eq!( + parse(&[("case-sensitive", "yes")]).err().unwrap(), + "KEY=case-sensitive VALUE=yes ERROR=expected 'true' or 'false'" + ); + } + + #[test] + fn more_specific_defer_overrides_general_deny() { + for config in [ + [("deny", "/data/**"), ("defer", "/data/public/*")], + [("defer", "/data/public/*"), ("deny", "/data/**")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/data/public/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/private/a.txt"), Some(Action::Deny)); + } + } + + #[test] + fn more_specific_deny_overrides_general_defer() { + for config in [ + [("defer", "/data/**"), ("deny", "/data/*.key")], + [("deny", "/data/*.key"), ("defer", "/data/**")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/data/id.key"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/id.pub"), Some(Action::Defer)); + } + } + + #[test] + fn nested_exceptions() { + let patterns = patterns(&[ + ("deny-1", "/**"), + ("defer-1", "/data/**"), + ("deny-2", "/data/secret.txt"), + ]); + assert_eq!(action(&patterns, "/etc/passwd"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/secret.txt"), Some(Action::Deny)); + } + + #[test] + fn segments_closer_to_the_root_are_more_significant() { + // `data` is a literal where the other pattern has a wildcard + let patterns = patterns(&[("deny", "/*/a.txt"), ("defer", "/data/*")]); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + } + + #[test] + fn literal_segment_beats_wildcards() { + let star = patterns(&[("deny", "/*"), ("defer", "/a.txt")]); + assert_eq!(action(&star, "/a.txt"), Some(Action::Defer)); + assert_eq!(action(&star, "/b.txt"), Some(Action::Deny)); + + let partial = patterns(&[("deny", "/a*"), ("defer", "/a.txt")]); + assert_eq!(action(&partial, "/a.txt"), Some(Action::Defer)); + assert_eq!(action(&partial, "/ab.txt"), Some(Action::Deny)); + } + + #[test] + fn partial_wildcard_with_more_literals_wins() { + let patterns = patterns(&[("deny", "/*.txt"), ("defer", "/read*.txt")]); + assert_eq!(action(&patterns, "/readme.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/notes.txt"), Some(Action::Deny)); + } + + #[test] + fn single_star_beats_double_star() { + let patterns = patterns(&[("deny", "/data/**"), ("defer", "/data/*")]); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/dir/a.txt"), Some(Action::Deny)); + } + + #[test] + fn deny_wins_when_equally_specific() { + for config in [ + [("deny", "/a*"), ("defer", "/*z")], + [("defer", "/*z"), ("deny", "/a*")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/abz"), Some(Action::Deny)); + } + } + + #[test] + fn specificity_ranks_segments_from_the_root() { + assert_eq!(specificity("/**/re?d*.txt/*"), vec![(0, 0), (3, 7), (2, 0)]); + assert_eq!(specificity("[abc]x/data"), vec![(3, 1), (4, 4)]); + } + + #[test] + fn a_deferred_path_does_not_hide_a_denied_one() { + let patterns = patterns(&[("default", "deny"), ("defer", "/data/*")]); + assert_eq!( + decision(&patterns, "/data", &["a.txt", "b.txt"]), + deferred() + ); + assert_eq!( + decision(&patterns, "/data", &["a.txt", "../etc/passwd"]), + denied(FilesystemErrorCode::Access) + ); + } + + #[test] + fn default_deny_uses_configured_reason_regardless_of_order() { + for config in [ + [("reason", "no-entry"), ("default", "deny")], + [("default", "deny"), ("reason", "no-entry")], + ] { + let patterns = patterns(&config); + assert_eq!( + decision(&patterns, "/a", &[]), + denied(FilesystemErrorCode::NoEntry) + ); + } + } + + #[test] + fn reason_does_not_change_the_default() { + let patterns = patterns(&[("reason", "no-entry")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn default_defer() { + let patterns = patterns(&[("default", "defer")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn matching_pattern_overrides_default() { + let patterns = patterns(&[("default", "deny"), ("defer", "/data/**")]); + assert_eq!(decision(&patterns, "/data/a.txt", &[]), deferred()); + assert_eq!( + decision(&patterns, "/etc/passwd", &[]), + denied(FilesystemErrorCode::Access) + ); + } + + #[test] + fn unknown_default_is_invalid() { + assert_eq!( + parse(&[("default", "grant")]).err().unwrap(), + "KEY=default VALUE=grant ERROR=expected 'deny' or 'defer'" + ); + } + + #[test] + fn readonly_paths_deny_mutations_with_read_only() { + let patterns = patterns(&[("readonly", "/data/**")]); + let read_only = denied(FilesystemErrorCode::ReadOnly); + assert_eq!(mutation(&patterns, "/data", &["a.txt"]), read_only); + assert_eq!(decision(&patterns, "/data", &["a.txt"]), deferred()); + assert_eq!(mutation(&patterns, "/data/a.txt", &[]), read_only); + assert_eq!(mutation(&patterns, "/other", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_paths_allow_reads() { + let patterns = patterns(&[("readonly", "/data/**")]); + assert_eq!(decision(&patterns, "/data", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_ignores_the_configured_reason() { + let patterns = patterns(&[("reason", "no-entry"), ("readonly", "/data/**")]); + assert_eq!( + mutation(&patterns, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + } + + #[test] + fn readonly_is_only_evaluated_when_otherwise_deferred() { + let denying = patterns(&[ + ("reason", "no-entry"), + ("deny", "/data/secret/*"), + ("readonly", "/data/**"), + ]); + // the deny pattern decides first, with the configured reason + assert_eq!( + mutation(&denying, "/data/secret", &["a.txt"]), + denied(FilesystemErrorCode::NoEntry) + ); + assert_eq!( + mutation(&denying, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + assert_eq!(decision(&denying, "/data", &["a.txt"]), deferred()); + + let default_deny = patterns(&[("default", "deny"), ("readonly", "/data/**")]); + assert_eq!( + mutation(&default_deny, "/data", &["a.txt"]), + denied(FilesystemErrorCode::Access) + ); + } + + #[test] + fn deferring_pattern_does_not_override_readonly() { + let patterns = patterns(&[ + ("default", "deny"), + ("defer", "/data/**"), + ("readonly", "/data/archive/**"), + ]); + assert_eq!(mutation(&patterns, "/data", &["a.txt"]), deferred()); + assert_eq!( + mutation(&patterns, "/data/archive", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + assert_eq!(decision(&patterns, "/data/archive", &["a.txt"]), deferred()); + assert_eq!( + mutation(&patterns, "/", &["a.txt"]), + denied(FilesystemErrorCode::Access) + ); + } + + #[test] + fn any_readonly_path_denies_a_mutation() { + let patterns = patterns(&[("readonly", "/data/archive/**")]); + // moving a file out of a readonly directory changes it + assert_eq!( + mutation(&patterns, "/data", &["archive/a.txt", "a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + } + + #[test] + fn readonly_matching_follows_case_sensitivity() { + let insensitive = patterns(&[("readonly", "/Data/**")]); + assert_eq!( + mutation(&insensitive, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + let sensitive = patterns(&[("case-sensitive", "true"), ("readonly", "/Data/**")]); + assert_eq!(mutation(&sensitive, "/data", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_descriptor_mutations() { + let patterns = patterns(&[("readonly", "/data/**")]); + let decide = + |path: &str| format!("{:?}", patterns.readonly_decision(&[PathBuf::from(path)])); + assert_eq!(decide("/data/a.txt"), denied(FilesystemErrorCode::ReadOnly)); + assert_eq!(decide("/other/a.txt"), deferred()); + } + + #[test] + fn opening_for_mutation() { + let read = DescriptorFlags::READ; + assert_eq!(open_access(OpenFlags::empty(), read), Access::Read); + assert_eq!(open_access(OpenFlags::DIRECTORY, read), Access::Read); + for open_flags in [OpenFlags::CREATE, OpenFlags::EXCLUSIVE, OpenFlags::TRUNCATE] { + assert_eq!( + open_access(open_flags, read), + Access::Mutate, + "{open_flags:?}" + ); + } + for flags in [ + DescriptorFlags::WRITE, + DescriptorFlags::FILE_INTEGRITY_SYNC, + DescriptorFlags::DATA_INTEGRITY_SYNC, + DescriptorFlags::REQUESTED_WRITE_SYNC, + DescriptorFlags::MUTATE_DIRECTORY, + ] { + assert_eq!( + open_access(OpenFlags::empty(), read | flags), + Access::Mutate, + "{flags:?}" + ); + } + } + + #[test] + fn invalid_readonly_glob_is_invalid() { + let err = parse(&[("readonly", "[")]).err().unwrap(); + assert!(err.starts_with("KEY=readonly VALUE=[ ERROR="), "{err}"); + } + + #[test] + fn double_star_matches_every_path() { + let patterns = patterns(&[("readonly", "**")]); + for path in ["/", "/a.txt", "/a/b/c.txt", ".", "./a.txt", "data", "data/a/b.txt", ".hidden/.x"] { + assert_eq!( + format!("{:?}", patterns.readonly_decision(&[PathBuf::from(path)])), + denied(FilesystemErrorCode::ReadOnly), + "{path}" + ); + } + } + + #[test] + fn invalid_glob_is_invalid() { + let err = parse(&[("deny-1", "/a"), ("deny-2", "[")]).err().unwrap(); + assert!(err.starts_with("KEY=deny-2 VALUE=[ ERROR="), "{err}"); + } +} diff --git a/components/latch-readonly-config/README.md b/components/latch-readonly-config/README.md new file mode 100644 index 0000000..dbda123 --- /dev/null +++ b/components/latch-readonly-config/README.md @@ -0,0 +1,15 @@ +# `latch-readonly-config` + +Config for `latch-path-glob` that makes every path readonly. + +Every operation that would change the filesystem is denied with `read-only`, every other operation is deferred. + +```properties +readonly=** +``` + +## Interfaces + +Exports: + +- `wasi:config/store@0.2.0-rc.1`: the config diff --git a/components/latch-readonly-config/latch-readonly-config.properties b/components/latch-readonly-config/latch-readonly-config.properties new file mode 100644 index 0000000..b639162 --- /dev/null +++ b/components/latch-readonly-config/latch-readonly-config.properties @@ -0,0 +1 @@ +readonly=** diff --git a/components/latch-readonly/Cargo.toml b/components/latch-readonly/Cargo.toml new file mode 100644 index 0000000..d8f2ea2 --- /dev/null +++ b/components/latch-readonly/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "latch-readonly-tests" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# only tests, the component is built from latch-readonly.wac + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-readonly/README.md b/components/latch-readonly/README.md new file mode 100644 index 0000000..087a717 --- /dev/null +++ b/components/latch-readonly/README.md @@ -0,0 +1,17 @@ +# `latch-readonly` + +Filesystem latch that denies operations which would mutate the filesystem. + +`latch-path-glob` composed with [`latch-readonly-config`](../latch-readonly-config/). Operations that would change the filesystem are denied with `read-only`, every other operation is deferred. See [`latch-path-glob`](../latch-path-glob/) for the operations that change the filesystem. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-readonly/latch-readonly.wac b/components/latch-readonly/latch-readonly.wac new file mode 100644 index 0000000..7c2baba --- /dev/null +++ b/components/latch-readonly/latch-readonly.wac @@ -0,0 +1,6 @@ +package componentized:filesystem; + +export new local:latch-path-glob { + store: new local:latch-readonly-config{}.store, + ... +}...; diff --git a/components/latch-readonly/tests/latch.rs b/components/latch-readonly/tests/latch.rs new file mode 100644 index 0000000..361f004 --- /dev/null +++ b/components/latch-readonly/tests/latch.rs @@ -0,0 +1,212 @@ +//! Tests for `latch-readonly`, composed from `latch-readonly.wac`, as the gate's latch. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`, so any denial comes from the +//! latch rather than the host: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! empty/ +//! dir/nested.txt "nested" +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{GuestDescriptor, NewTimestamp}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, OpenFlags, PathFlags, Perms, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +fn fixture() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("file.txt"), "hello").unwrap(); + symlink("file.txt", root.join("link")).unwrap(); + fs::create_dir(root.join("empty")).unwrap(); + fs::create_dir(root.join("dir")).unwrap(); + fs::write(root.join("dir/nested.txt"), "nested").unwrap(); + dir +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn root(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + Ok(dirs.into_iter().next().expect("a preopen").0) +} + +async fn open( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, + path: &str, + open_flags: OpenFlags, + flags: DescriptorFlags, +) -> wasmtime::Result> { + descriptor(exports) + .call_open_at( + accessor, + fd, + PathFlags::empty(), + path.into(), + open_flags, + flags, + ) + .await +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn reads_are_deferred() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-readonly") + .build() + .await?; + let (data, stat, target, nested) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let read = DescriptorFlags::READ; + let file = open(accessor, exports, root, "file.txt", OpenFlags::empty(), read) + .await? + .expect("open file.txt"); + let (data, _) = d.call_read_via_stream(accessor, file, 0).await?; + let mut data = test_harness::collect(accessor, data)?; + let mut bytes = vec![]; + while let Some(byte) = data.recv().await { + bytes.push(byte); + } + let stat = d.call_stat(accessor, file).await?.expect("stat"); + let target = d.call_readlink_at(accessor, root, "link".into()).await?; + let nested = ok(open( + accessor, + exports, + root, + "dir/nested.txt", + OpenFlags::empty(), + read, + ) + .await?); + Ok((bytes, stat, target, nested)) + }) + .await?; + assert_eq!(data, b"hello"); + assert_eq!(stat.type_, DescriptorType::RegularFile); + assert_eq!(target, Ok("file.txt".into())); + assert_eq!(nested, Ok(())); + assert_eq!(gate.recorder().logs(), []); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn mutations_are_denied_with_read_only() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-readonly") + .build() + .await?; + let results = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let read = DescriptorFlags::READ; + let file = open(accessor, exports, root, "file.txt", OpenFlags::empty(), read) + .await? + .expect("open file.txt"); + let now = || NewTimestamp::Now; + let mut results = vec![]; + for (path, open_flags, flags) in [ + ("file.txt", OpenFlags::empty(), read | DescriptorFlags::WRITE), + ("file.txt", OpenFlags::TRUNCATE, read), + ("new.txt", OpenFlags::CREATE, read), + (".", OpenFlags::DIRECTORY, read | DescriptorFlags::MUTATE_DIRECTORY), + ] { + results.push(ok( + open(accessor, exports, root, path, open_flags, flags).await? + )); + } + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_write_via_stream(accessor, file, data, 0).await?; + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_append_via_stream(accessor, file, data).await?; + results.push(test_harness::resolve(accessor, result).await?); + results.push(d.call_set_size(accessor, file, 0).await?); + results.push(d.call_set_times(accessor, root, now(), now()).await?); + results.push(d.call_sync(accessor, file).await?); + results.push(d.call_sync_data(accessor, file).await?); + results.push( + d.call_create_directory_at(accessor, root, "new".into()) + .await?, + ); + results.push( + d.call_set_times_at( + accessor, + root, + PathFlags::empty(), + "file.txt".into(), + now(), + now(), + ) + .await?, + ); + results.push( + d.call_link_at( + accessor, + root, + PathFlags::empty(), + "file.txt".into(), + root, + "hard.txt".into(), + ) + .await?, + ); + results.push( + d.call_rename_at(accessor, root, "file.txt".into(), root, "moved.txt".into()) + .await?, + ); + results.push( + d.call_symlink_at(accessor, root, "file.txt".into(), "new-link".into()) + .await?, + ); + results.push( + d.call_remove_directory_at(accessor, root, "empty".into()) + .await?, + ); + results.push( + d.call_unlink_file_at(accessor, root, "link".into()) + .await?, + ); + Ok(results) + }) + .await?; + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Err(ErrorCode::ReadOnly), "operation {index}"); + } + + // nothing was changed + let root = dir.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"hello"); + assert!(root.join("empty").is_dir()); + assert!(root.join("link").is_symlink()); + for path in ["new.txt", "new", "hard.txt", "moved.txt", "new-link"] { + assert!(!root.join(path).exists(), "{path}"); + } + Ok(()) +} diff --git a/components/readonly/README.md b/components/readonly/README.md deleted file mode 100644 index fd6a97e..0000000 --- a/components/readonly/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# `readonly` - -Virtualizes the `wasi:filesystem` interfaces making the exported file system read-only. - -Every operation that reads the filesystem is passed through, every operation that would change it fails with `read-only`, including opening a descriptor with flags that would allow a change. `sync` and `sync-data` are denied as well. - -`write-via-stream` and `append-via-stream` are synchronous and resolve their result on a background thread, so the host must support component model threads, e.g. `wasmtime -W component-model-threading`. diff --git a/components/readonly/src/lib.rs b/components/readonly/src/lib.rs deleted file mode 100644 index 3a12d07..0000000 --- a/components/readonly/src/lib.rs +++ /dev/null @@ -1,243 +0,0 @@ -use exports::wasi::filesystem::preopens::Guest as Preopens; -use exports::wasi::filesystem::types::{ - Advice, Descriptor, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, - DirectoryEntry, ErrorCode, Filesize, Guest as Types, MetadataHashValue, NewTimestamp, - OpenFlags, PathFlags, -}; -use wasi::filesystem::preopens; -use wasi::filesystem::types; - -struct FilesystemReadOnly {} - -impl Preopens for FilesystemReadOnly { - #[doc = " Return the set of preopened directories, and their path."] - fn get_directories() -> Vec<(Descriptor, String)> { - preopens::get_directories() - .into_iter() - .map(|(fd, path)| { - let fd = Descriptor::new(ReadOnlyDescriptor::new(fd)); - (fd, path) - }) - .collect() - } -} - -impl Types for FilesystemReadOnly { - type Descriptor = ReadOnlyDescriptor; -} - -struct ReadOnlyDescriptor { - fd: types::Descriptor, -} - -impl ReadOnlyDescriptor { - fn new(fd: types::Descriptor) -> Self { - Self { fd } - } -} - -impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { - fn read_via_stream( - &self, - offset: Filesize, - ) -> ( - wit_bindgen::StreamReader, - wit_bindgen::FutureReader>, - ) { - self.fd.read_via_stream(offset) - } - - fn write_via_stream( - &self, - _data: wit_bindgen::StreamReader, - _offset: Filesize, - ) -> wit_bindgen::FutureReader> { - read_only() - } - - fn append_via_stream( - &self, - _data: wit_bindgen::StreamReader, - ) -> wit_bindgen::FutureReader> { - read_only() - } - - async fn advise( - &self, - offset: Filesize, - length: Filesize, - advice: Advice, - ) -> Result<(), ErrorCode> { - self.fd.advise(offset, length, advice).await - } - - async fn sync_data(&self) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn get_flags(&self) -> Result { - self.fd.get_flags().await.map(|flags| { - flags - .difference(DescriptorFlags::WRITE) - .difference(DescriptorFlags::FILE_INTEGRITY_SYNC) - .difference(DescriptorFlags::DATA_INTEGRITY_SYNC) - .difference(DescriptorFlags::MUTATE_DIRECTORY) - }) - } - - async fn get_type(&self) -> Result { - self.fd.get_type().await - } - - async fn set_size(&self, _size: Filesize) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn set_times( - &self, - _data_access_timestamp: NewTimestamp, - _data_modification_timestamp: NewTimestamp, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - fn read_directory( - &self, - ) -> ( - wit_bindgen::StreamReader, - wit_bindgen::FutureReader>, - ) { - self.fd.read_directory() - } - - async fn sync(&self) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn create_directory_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn stat(&self) -> Result { - self.fd.stat().await - } - - async fn stat_at( - &self, - path_flags: PathFlags, - path: String, - ) -> Result { - self.fd.stat_at(path_flags, path).await - } - - async fn set_times_at( - &self, - _path_flags: PathFlags, - _path: String, - _data_access_timestamp: NewTimestamp, - _data_modification_timestamp: NewTimestamp, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn link_at( - &self, - _old_path_flags: PathFlags, - _old_path: String, - _new_descriptor: DescriptorBorrow<'_>, - _new_path: String, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn open_at( - &self, - path_flags: PathFlags, - path: String, - open_flags: OpenFlags, - flags: DescriptorFlags, - ) -> Result { - if open_flags.contains(OpenFlags::CREATE) - || open_flags.contains(OpenFlags::EXCLUSIVE) - || open_flags.contains(OpenFlags::TRUNCATE) - || flags.contains(DescriptorFlags::WRITE) - || flags.contains(DescriptorFlags::FILE_INTEGRITY_SYNC) - || flags.contains(DescriptorFlags::DATA_INTEGRITY_SYNC) - || flags.contains(DescriptorFlags::REQUESTED_WRITE_SYNC) - || flags.contains(DescriptorFlags::MUTATE_DIRECTORY) - { - return Err(ErrorCode::ReadOnly); - } - - match self - .fd - .open_at(path_flags, path.clone(), open_flags, flags) - .await - { - Ok(fd) => Ok(Descriptor::new(ReadOnlyDescriptor::new(fd))), - Err(error_code) => Err(error_code), - } - } - - async fn readlink_at(&self, path: String) -> Result { - self.fd.readlink_at(path).await - } - - async fn remove_directory_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn rename_at( - &self, - _old_path: String, - _new_descriptor: DescriptorBorrow<'_>, - _new_path: String, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn symlink_at(&self, _old_path: String, _new_path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn unlink_file_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn is_same_object(&self, other: DescriptorBorrow<'_>) -> bool { - let other: &Self = other.get(); - self.fd.is_same_object(&other.fd).await - } - - async fn metadata_hash(&self) -> Result { - self.fd.metadata_hash().await - } - - async fn metadata_hash_at( - &self, - path_flags: PathFlags, - path: String, - ) -> Result { - self.fd.metadata_hash_at(path_flags, path).await - } -} - -/// A future resolved to `read-only`, for the sync exports that return a future. -fn read_only() -> wit_bindgen::FutureReader> { - let (tx, rx) = wit_future::new(|| Err(ErrorCode::ReadOnly)); - // a sync export can't wait for the write, the reader has not been returned yet, so the write - // completes in the background - componentized_rt::executor::spawn(async move { - let _ = tx.write(Err(ErrorCode::ReadOnly)).await; - }); - rx -} - -wit_bindgen::generate!({ - path: "../wit", - world: "filesystem", - merge_structurally_equal_types: true, - generate_all -}); - -export!(FilesystemReadOnly); diff --git a/components/readonly/tests/readonly.rs b/components/readonly/tests/readonly.rs deleted file mode 100644 index da1938d..0000000 --- a/components/readonly/tests/readonly.rs +++ /dev/null @@ -1,821 +0,0 @@ -//! The readonly component allows every operation that reads the filesystem and denies every -//! operation that would change it with `read-only`. -//! -//! Each test preopens a fresh copy of this tree read-write, so any denial comes from the component -//! rather than the host: -//! -//! ```text -//! file.txt "hello world" -//! link -> file.txt -//! empty/ -//! dir/nested.txt "nested" -//! ``` -//! -//! Tests of mutations compare a snapshot of the tree before and after, nothing may change. - -#![cfg(unix)] - -use std::collections::BTreeMap; -use std::fs; -use std::os::unix::fs::symlink; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use tempfile::TempDir; -use test_harness::bindings::exports::wasi::filesystem::types::{ - Advice, GuestDescriptor, NewTimestamp, -}; -use test_harness::{ - Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, OpenFlags, PathFlags, Perms, - TestSubject, -}; -use wasmtime::component::{Accessor, ResourceAny}; - -const FILE: &str = "hello world"; -const NESTED: &str = "nested"; - -struct Fixture { - dir: TempDir, -} - -/// What a test can observe about one entry of the tree. -#[derive(Debug, PartialEq, Eq)] -enum Entry { - // not the access time, reads may change it, as does taking the snapshot - File { - content: Vec, - modified: SystemTime, - }, - Dir { - modified: SystemTime, - }, - Link { - target: PathBuf, - }, -} - -impl Fixture { - fn new() -> Self { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - fs::write(root.join("file.txt"), FILE).unwrap(); - symlink("file.txt", root.join("link")).unwrap(); - fs::create_dir(root.join("empty")).unwrap(); - fs::create_dir(root.join("dir")).unwrap(); - fs::write(root.join("dir/nested.txt"), NESTED).unwrap(); - Self { dir } - } - - fn path(&self) -> &Path { - self.dir.path() - } - - /// Every entry of the tree, by its path relative to the root. - fn snapshot(&self) -> BTreeMap { - fn walk(root: &Path, dir: &Path, entries: &mut BTreeMap) { - for entry in fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - let meta = fs::symlink_metadata(&path).unwrap(); - let relative = path.strip_prefix(root).unwrap().to_path_buf(); - if meta.is_symlink() { - let target = fs::read_link(&path).unwrap(); - entries.insert(relative, Entry::Link { target }); - } else if meta.is_dir() { - let modified = meta.modified().unwrap(); - entries.insert(relative, Entry::Dir { modified }); - walk(root, &path, entries); - } else { - let content = fs::read(&path).unwrap(); - let modified = meta.modified().unwrap(); - entries.insert(relative, Entry::File { content, modified }); - } - } - } - let mut entries = BTreeMap::new(); - walk(self.path(), self.path(), &mut entries); - entries - } - - async fn readonly(&self) -> TestSubject { - Harness::new("readonly") - .preopen(self.path(), "/", Perms::ReadWrite) - .build() - .await - .expect("readonly") - } -} - -/// Descriptors opened through the readonly component. -#[derive(Clone, Copy)] -struct Handles { - /// The preopen - root: ResourceAny, - /// `dir`, opened from the root - dir: ResourceAny, - /// `file.txt`, opened from the root - file: ResourceAny, - /// `dir/nested.txt`, opened from `dir` - nested: ResourceAny, -} - -fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { - exports.wasi_filesystem_types().descriptor() -} - -async fn open( - accessor: &Accessor, - exports: &Exports, - fd: ResourceAny, - path: &str, - open_flags: OpenFlags, - flags: DescriptorFlags, -) -> wasmtime::Result> { - descriptor(exports) - .call_open_at( - accessor, - fd, - PathFlags::empty(), - path.into(), - open_flags, - flags, - ) - .await -} - -async fn handles(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { - let dirs = exports - .wasi_filesystem_preopens() - .call_get_directories(accessor) - .await?; - let (root, _) = dirs.into_iter().next().expect("a preopen"); - let read = DescriptorFlags::READ; - let dir = open(accessor, exports, root, "dir", OpenFlags::DIRECTORY, read) - .await? - .expect("open dir"); - let file = open( - accessor, - exports, - root, - "file.txt", - OpenFlags::empty(), - read, - ) - .await? - .expect("open file.txt"); - let nested = open( - accessor, - exports, - dir, - "nested.txt", - OpenFlags::empty(), - read, - ) - .await? - .expect("open dir/nested.txt"); - Ok(Handles { - root, - dir, - file, - nested, - }) -} - -/// Run the operations, each must be denied with `read-only` and leave the tree unchanged. -async fn assert_denied( - f: impl AsyncFnOnce( - &Accessor, - &Exports, - Handles, - ) -> wasmtime::Result>> - + Send, -) { - let fixture = Fixture::new(); - let before = fixture.snapshot(); - let mut readonly = fixture.readonly().await; - let results = readonly - .run(async |accessor, exports| { - let handles = handles(accessor, exports).await?; - f(accessor, exports, handles).await - }) - .await - .expect("run"); - assert!(!results.is_empty()); - for (index, result) in results.into_iter().enumerate() { - assert_eq!(result, Err(ErrorCode::ReadOnly), "operation {index}"); - } - assert_eq!(fixture.snapshot(), before); -} - -/// Run reads against the readonly component, the tree must be unchanged after. -async fn read( - f: impl AsyncFnOnce(&Accessor, &Exports, Handles) -> wasmtime::Result + Send, -) -> R { - let fixture = Fixture::new(); - let before = fixture.snapshot(); - let mut readonly = fixture.readonly().await; - let result = readonly - .run(async |accessor, exports| { - let handles = handles(accessor, exports).await?; - f(accessor, exports, handles).await - }) - .await - .expect("run"); - assert_eq!(fixture.snapshot(), before); - result -} - -fn ok(result: Result) -> Result<(), E> { - result.map(|_| ()) -} - -// --- reads are allowed --- - -#[tokio::test(flavor = "multi_thread")] -async fn preopens_are_passed_through() { - let fixture = Fixture::new(); - let mut readonly = Harness::new("readonly") - .preopen(fixture.path(), "/", Perms::ReadWrite) - .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) - .build() - .await - .unwrap(); - let names = readonly - .run(async |accessor, exports| { - let dirs = exports - .wasi_filesystem_preopens() - .call_get_directories(accessor) - .await?; - Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) - }) - .await - .unwrap(); - assert_eq!(names, ["/", "/data"]); -} - -#[tokio::test(flavor = "multi_thread")] -async fn files_and_directories_are_opened_for_reading() { - let results = read(async |accessor, exports, h| { - let read = DescriptorFlags::READ; - let mut results = vec![]; - for (fd, path, open_flags) in [ - (h.root, "file.txt", OpenFlags::empty()), - (h.root, ".", OpenFlags::DIRECTORY), - (h.root, "empty", OpenFlags::DIRECTORY), - (h.root, "dir/nested.txt", OpenFlags::empty()), - (h.dir, "nested.txt", OpenFlags::empty()), - (h.dir, ".", OpenFlags::DIRECTORY), - ] { - results.push(ok( - open(accessor, exports, fd, path, open_flags, read).await? - )); - } - // following a symlink is a read - results.push(ok(descriptor(exports) - .call_open_at( - accessor, - h.root, - PathFlags::SYMLINK_FOLLOW, - "link".into(), - OpenFlags::empty(), - read, - ) - .await?)); - Ok(results) - }) - .await; - for (index, result) in results.into_iter().enumerate() { - assert_eq!(result, Ok(()), "open {index}"); - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn opening_a_missing_file_is_the_host_error() { - let result = read(async |accessor, exports, h| { - Ok(ok(open( - accessor, - exports, - h.root, - "missing.txt", - OpenFlags::empty(), - DescriptorFlags::READ, - ) - .await?)) - }) - .await; - assert_eq!(result, Err(ErrorCode::NoEntry)); -} - -#[tokio::test(flavor = "multi_thread")] -async fn files_are_read() { - let (whole, offset, nested) = read(async |accessor, exports, h| { - async fn read_all( - accessor: &Accessor, - exports: &Exports, - fd: ResourceAny, - offset: u64, - ) -> wasmtime::Result { - let (data, _) = descriptor(exports) - .call_read_via_stream(accessor, fd, offset) - .await?; - let mut data = test_harness::collect(accessor, data)?; - let mut bytes = vec![]; - while let Some(byte) = data.recv().await { - bytes.push(byte); - } - Ok(String::from_utf8(bytes)?) - } - Ok(( - read_all(accessor, exports, h.file, 0).await?, - read_all(accessor, exports, h.file, 6).await?, - read_all(accessor, exports, h.nested, 0).await?, - )) - }) - .await; - assert_eq!(whole, FILE); - assert_eq!(offset, "world"); - assert_eq!(nested, NESTED); -} - -#[tokio::test(flavor = "multi_thread")] -async fn directories_are_listed() { - // the entries and result are passed through from the host, which the harness can't read, see - // `test_harness::resolve`, the entries are listed from the opened directories instead - let opened = read(async |accessor, exports, h| { - let d = descriptor(exports); - for fd in [h.root, h.dir] { - d.call_read_directory(accessor, fd).await?; - } - let read = DescriptorFlags::READ; - let mut opened = vec![]; - for path in ["dir", "empty", "file.txt", "dir/nested.txt"] { - let open_flags = match path { - "dir" | "empty" => OpenFlags::DIRECTORY, - _ => OpenFlags::empty(), - }; - opened.push(ok( - open(accessor, exports, h.root, path, open_flags, read).await? - )); - } - Ok(opened) - }) - .await; - assert!(opened.iter().all(Result::is_ok), "{opened:?}"); -} - -#[tokio::test(flavor = "multi_thread")] -async fn metadata_is_read() { - let results = read(async |accessor, exports, h| { - let d = descriptor(exports); - let file = d.call_stat(accessor, h.file).await?.expect("stat"); - let root = d.call_stat(accessor, h.root).await?.expect("stat root"); - let file_at = d - .call_stat_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) - .await? - .expect("stat-at"); - let link = d - .call_stat_at(accessor, h.root, PathFlags::empty(), "link".into()) - .await? - .expect("stat-at link"); - let followed = d - .call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) - .await? - .expect("stat-at followed"); - let types = ( - d.call_get_type(accessor, h.file).await?, - d.call_get_type(accessor, h.dir).await?, - ); - Ok((file, root, file_at, link, followed, types)) - }) - .await; - let (file, root, file_at, link, followed, types) = results; - assert_eq!(file.type_, DescriptorType::RegularFile); - assert_eq!(file.size, FILE.len() as u64); - assert_eq!(root.type_, DescriptorType::Directory); - assert_eq!(file_at, file); - assert_eq!(link.type_, DescriptorType::SymbolicLink); - assert_eq!(followed, file); - assert_eq!( - types, - ( - Ok(DescriptorType::RegularFile), - Ok(DescriptorType::Directory) - ) - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn metadata_hashes_are_read() { - let (file, file_at, followed, other) = read(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(( - d.call_metadata_hash(accessor, h.file).await?, - d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) - .await?, - d.call_metadata_hash_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) - .await?, - d.call_metadata_hash(accessor, h.nested).await?, - )) - }) - .await; - assert!(file.is_ok(), "{file:?}"); - assert_eq!(file_at, file); - assert_eq!(followed, file); - assert_ne!(other, file); -} - -#[tokio::test(flavor = "multi_thread")] -async fn links_are_read() { - let target = read(async |accessor, exports, h| { - Ok(descriptor(exports) - .call_readlink_at(accessor, h.root, "link".into()) - .await?) - }) - .await; - assert_eq!(target, Ok("file.txt".into())); -} - -#[tokio::test(flavor = "multi_thread")] -async fn descriptors_are_compared() { - let (same, different) = read(async |accessor, exports, h| { - let again = open( - accessor, - exports, - h.root, - "dir", - OpenFlags::DIRECTORY, - DescriptorFlags::READ, - ) - .await? - .expect("open dir"); - let d = descriptor(exports); - Ok(( - d.call_is_same_object(accessor, h.dir, again).await?, - d.call_is_same_object(accessor, h.dir, h.root).await?, - )) - }) - .await; - assert!(same); - assert!(!different); -} - -#[tokio::test(flavor = "multi_thread")] -async fn advice_is_given() { - let result = read(async |accessor, exports, h| { - Ok(descriptor(exports) - .call_advise(accessor, h.file, 0, 0, Advice::Sequential) - .await?) - }) - .await; - assert_eq!(result, Ok(())); -} - -#[tokio::test(flavor = "multi_thread")] -async fn flags_are_read_only() { - let flags = read(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_get_flags(accessor, h.root).await?, - d.call_get_flags(accessor, h.dir).await?, - d.call_get_flags(accessor, h.file).await?, - d.call_get_flags(accessor, h.nested).await?, - ]) - }) - .await; - // the host preopen is writable, the readonly descriptors are not - for (index, flags) in flags.into_iter().enumerate() { - assert_eq!(flags, Ok(DescriptorFlags::READ), "descriptor {index}"); - } -} - -// --- mutations are denied --- - -/// Every combination of open flags and descriptor flags that would let a descriptor change the -/// filesystem. -fn mutating_opens() -> Vec<(OpenFlags, DescriptorFlags)> { - let read = DescriptorFlags::READ; - vec![ - (OpenFlags::CREATE, read), - (OpenFlags::CREATE | OpenFlags::EXCLUSIVE, read), - (OpenFlags::EXCLUSIVE, read), - (OpenFlags::TRUNCATE, read), - (OpenFlags::empty(), DescriptorFlags::WRITE), - (OpenFlags::empty(), read | DescriptorFlags::WRITE), - ( - OpenFlags::empty(), - read | DescriptorFlags::FILE_INTEGRITY_SYNC, - ), - ( - OpenFlags::empty(), - read | DescriptorFlags::DATA_INTEGRITY_SYNC, - ), - ( - OpenFlags::empty(), - read | DescriptorFlags::REQUESTED_WRITE_SYNC, - ), - (OpenFlags::empty(), read | DescriptorFlags::MUTATE_DIRECTORY), - ] -} - -#[tokio::test(flavor = "multi_thread")] -async fn opening_existing_files_for_mutation_is_denied() { - assert_denied(async |accessor, exports, h| { - let mut results = vec![]; - for (open_flags, flags) in mutating_opens() { - for (fd, path) in [ - (h.root, "file.txt"), - (h.root, "link"), - (h.dir, "nested.txt"), - ] { - results.push(ok( - open(accessor, exports, fd, path, open_flags, flags).await? - )); - } - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn opening_new_files_is_denied() { - assert_denied(async |accessor, exports, h| { - let mut results = vec![]; - for (open_flags, flags) in mutating_opens() { - for (fd, path) in [ - (h.root, "new.txt"), - (h.dir, "new.txt"), - (h.root, "empty/new.txt"), - ] { - results.push(ok( - open(accessor, exports, fd, path, open_flags, flags).await? - )); - } - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn opening_directories_for_mutation_is_denied() { - assert_denied(async |accessor, exports, h| { - let read = DescriptorFlags::READ; - let mut results = vec![]; - for flags in [ - read | DescriptorFlags::MUTATE_DIRECTORY, - read | DescriptorFlags::WRITE, - ] { - for (fd, path) in [(h.root, "."), (h.root, "empty"), (h.dir, ".")] { - results.push(ok(open( - accessor, - exports, - fd, - path, - OpenFlags::DIRECTORY, - flags, - ) - .await?)); - } - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn writing_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - let mut results = vec![]; - for (fd, offset) in [(h.file, 0), (h.file, 6), (h.nested, 0)] { - let data = test_harness::stream(accessor, b"changed".to_vec())?; - let result = d.call_write_via_stream(accessor, fd, data, offset).await?; - results.push(test_harness::resolve(accessor, result).await?); - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn appending_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - let mut results = vec![]; - for fd in [h.file, h.nested] { - let data = test_harness::stream(accessor, b"changed".to_vec())?; - let result = d.call_append_via_stream(accessor, fd, data).await?; - results.push(test_harness::resolve(accessor, result).await?); - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn resizing_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_set_size(accessor, h.file, 0).await?, - d.call_set_size(accessor, h.file, 1024).await?, - d.call_set_size(accessor, h.nested, 0).await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn setting_times_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - let now = || NewTimestamp::Now; - let mut results = vec![]; - for fd in [h.file, h.root, h.dir, h.nested] { - results.push(d.call_set_times(accessor, fd, now(), now()).await?); - results.push( - d.call_set_times(accessor, fd, NewTimestamp::NoChange, NewTimestamp::Now) - .await?, - ); - } - for (fd, path_flags, path) in [ - (h.root, PathFlags::empty(), "file.txt"), - (h.root, PathFlags::SYMLINK_FOLLOW, "link"), - (h.root, PathFlags::empty(), "link"), - (h.root, PathFlags::empty(), "empty"), - (h.dir, PathFlags::empty(), "nested.txt"), - ] { - results.push( - d.call_set_times_at(accessor, fd, path_flags, path.into(), now(), now()) - .await?, - ); - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn syncing_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - let mut results = vec![]; - for fd in [h.file, h.root, h.nested] { - results.push(d.call_sync(accessor, fd).await?); - results.push(d.call_sync_data(accessor, fd).await?); - } - Ok(results) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn creating_directories_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_create_directory_at(accessor, h.root, "new".into()) - .await?, - d.call_create_directory_at(accessor, h.root, "empty/new".into()) - .await?, - d.call_create_directory_at(accessor, h.dir, "new".into()) - .await?, - // already exists, denied before the host is asked - d.call_create_directory_at(accessor, h.root, "empty".into()) - .await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn removing_directories_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_remove_directory_at(accessor, h.root, "empty".into()) - .await?, - d.call_remove_directory_at(accessor, h.root, "dir".into()) - .await?, - d.call_remove_directory_at(accessor, h.root, "missing".into()) - .await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn unlinking_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_unlink_file_at(accessor, h.root, "file.txt".into()) - .await?, - d.call_unlink_file_at(accessor, h.root, "link".into()) - .await?, - d.call_unlink_file_at(accessor, h.dir, "nested.txt".into()) - .await?, - d.call_unlink_file_at(accessor, h.root, "missing.txt".into()) - .await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn renaming_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_rename_at( - accessor, - h.root, - "file.txt".into(), - h.root, - "renamed.txt".into(), - ) - .await?, - d.call_rename_at( - accessor, - h.root, - "file.txt".into(), - h.dir, - "moved.txt".into(), - ) - .await?, - d.call_rename_at( - accessor, - h.dir, - "nested.txt".into(), - h.root, - "file.txt".into(), - ) - .await?, - d.call_rename_at(accessor, h.root, "empty".into(), h.root, "renamed".into()) - .await?, - d.call_rename_at( - accessor, - h.root, - "link".into(), - h.root, - "renamed-link".into(), - ) - .await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn hard_linking_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_link_at( - accessor, - h.root, - PathFlags::empty(), - "file.txt".into(), - h.root, - "hard.txt".into(), - ) - .await?, - d.call_link_at( - accessor, - h.root, - PathFlags::SYMLINK_FOLLOW, - "link".into(), - h.dir, - "hard.txt".into(), - ) - .await?, - d.call_link_at( - accessor, - h.dir, - PathFlags::empty(), - "nested.txt".into(), - h.root, - "hard.txt".into(), - ) - .await?, - ]) - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn symlinking_is_denied() { - assert_denied(async |accessor, exports, h| { - let d = descriptor(exports); - Ok(vec![ - d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) - .await?, - d.call_symlink_at(accessor, h.dir, "nested.txt".into(), "new-link".into()) - .await?, - // already exists, denied before the host is asked - d.call_symlink_at(accessor, h.root, "file.txt".into(), "link".into()) - .await?, - ]) - }) - .await; -} diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index e85c334..aba5dfd 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -3,8 +3,29 @@ package componentized:filesystem-components; world filesystem { import wasi:config/store@0.2.0-rc.1; import wasi:logging/logging@0.1.0-draft; + import componentized:filesystem/latch@0.3.0-dev; import wasi:filesystem/preopens@0.3.0; export wasi:filesystem/preopens@0.3.0; import wasi:filesystem/types@0.3.0; export wasi:filesystem/types@0.3.0; } + +world latch { + import wasi:logging/logging@0.1.0-draft; + import wasi:config/store@0.2.0-rc.1; + import componentized:filesystem/latch@0.3.0-dev; + export componentized:filesystem/latch@0.3.0-dev; +} + +world latch-n { + export componentized:filesystem/latch@0.3.0-dev; + import latch0: componentized:filesystem/latch@0.3.0-dev; + import latch1: componentized:filesystem/latch@0.3.0-dev; + import latch2: componentized:filesystem/latch@0.3.0-dev; + import latch3: componentized:filesystem/latch@0.3.0-dev; + import latch4: componentized:filesystem/latch@0.3.0-dev; +} + +world insecure-random { + import wasi:random/insecure@0.3.0; +} diff --git a/components/wkg.lock b/components/wkg.lock index d82b52c..b0389b6 100644 --- a/components/wkg.lock +++ b/components/wkg.lock @@ -28,3 +28,12 @@ registry = "wasi.dev" requirement = "=0.1.0-draft" version = "0.1.0-draft" digest = "sha256:09621a45b12b0a9cddc798517f778aac0e5ae4bd234077b3d70758d6cf625580" + +[[packages]] +name = "wasi:random" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:daa3c8897a68fbfc58b95c2e1de1b7af5be8c647bbb2cfeaa54c1e5b1ba9b772" diff --git a/crates/filesystem-latch-n/Cargo.toml b/crates/filesystem-latch-n/Cargo.toml new file mode 100644 index 0000000..a5ac91b --- /dev/null +++ b/crates/filesystem-latch-n/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "filesystem-latch-n" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/crates/filesystem-latch-n/src/lib.rs b/crates/filesystem-latch-n/src/lib.rs new file mode 100644 index 0000000..8577a4e --- /dev/null +++ b/crates/filesystem-latch-n/src/lib.rs @@ -0,0 +1,54 @@ +pub use crate::bindings::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, Guest as Latch, Operation, +}; +pub use crate::bindings::{latch0, latch1, latch2, latch3, latch4}; + +pub fn authorize( + operation: Operation, + authorizers: Vec) -> Result>, +) -> Result { + for authorize in authorizers { + match authorize(&operation)? { + Decision::Deferred => {} + Decision::Denied(error_code) => return Ok(Decision::Denied(error_code)), + } + } + Ok(Decision::Deferred) +} + +/// Pass the final decision to every nested latch, including latches that were not asked to +/// authorize the operation because an earlier latch denied it. +/// +/// Every observer is called even if an earlier one fails, the result is the first error. +pub fn observe_decision( + final_decision: Decision, + operation: Operation, + observers: Vec) -> Result<(), ErrorCode>>, +) -> Result<(), ErrorCode> { + let mut result = Ok(()); + for observe in observers { + if let Err(err) = observe(&final_decision, &operation) { + if result.is_ok() { + result = Err(err); + } + } + } + result +} + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch-n", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} diff --git a/crates/filesystem-latch/Cargo.toml b/crates/filesystem-latch/Cargo.toml new file mode 100644 index 0000000..6bd1de0 --- /dev/null +++ b/crates/filesystem-latch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "filesystem-latch" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +wit-bindgen = { workspace = true } + +[build-dependencies] +heck = { workspace = true } +wit-parser = "0.259" diff --git a/crates/filesystem-latch/build.rs b/crates/filesystem-latch/build.rs new file mode 100644 index 0000000..fb2f1b7 --- /dev/null +++ b/crates/filesystem-latch/build.rs @@ -0,0 +1,121 @@ +//! Generate code from the same WIT the bindings are generated from, so a case added to the WIT is +//! handled without changing the latches: +//! +//! - `error_code_from_name`, which parses the kebab-case name of every case of +//! `wasi:filesystem/types#error-code` without a payload. +//! - `operation_name`, the name of every latch operation, e.g. `descriptor.open-at`, and +//! `INTERFACE_NAMES` and `OPERATION_NAMES`, every interface and operation name. + +use std::fmt::Write as _; +use std::path::PathBuf; + +use heck::ToUpperCamelCase; +use wit_parser::{Interface, Resolve, Type, TypeDefKind}; + +const WIT: &str = "../../components/wit"; + +fn main() { + println!("cargo:rerun-if-changed={WIT}"); + + let mut resolve = Resolve::default(); + resolve.push_path(WIT).expect("WIT must parse"); + + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + std::fs::write(out.join("error_code.rs"), error_code(&resolve)).unwrap(); + std::fs::write(out.join("operation.rs"), operation(&resolve)).unwrap(); +} + +fn interface<'a>(resolve: &'a Resolve, namespace: &str, package: &str, name: &str) -> &'a Interface { + resolve + .interfaces + .iter() + .map(|(_, interface)| interface) + .find(|interface| { + interface.name.as_deref() == Some(name) + && interface.package.is_some_and(|id| { + let package_name = &resolve.packages[id].name; + package_name.namespace == namespace && package_name.name == package + }) + }) + .unwrap_or_else(|| panic!("{namespace}:{package}/{name} must be in the WIT")) +} + +fn error_code(resolve: &Resolve) -> String { + let types = interface(resolve, "wasi", "filesystem", "types"); + let error_code = types.types["error-code"]; + let TypeDefKind::Variant(variant) = &resolve.types[error_code].kind else { + panic!("wasi:filesystem/types#error-code must be a variant"); + }; + + let mut arms = String::new(); + for case in &variant.cases { + match (&case.ty, case.name.as_str()) { + (None, name) => { + // the case name as wit-bindgen generates it + let ident = name.to_upper_camel_case(); + writeln!(arms, " {name:?} => Some(FilesystemErrorCode::{ident}),").unwrap(); + } + // `other` carries a message, it is parsed by hand + (Some(_), "other") => {} + (Some(_), name) => panic!( + "error-code case `{name}` has a payload, extend build.rs to parse it" + ), + } + } + + format!( + "/// The `error-code` case without a payload with the kebab-case name, e.g. `not-permitted`.\n\ + pub fn error_code_from_name(name: &str) -> Option {{\n \ + match name {{\n{arms} _ => None,\n }}\n\ + }}\n" + ) +} + +/// The latch's `operation` variant has a case for each interface, its payload is a variant with a +/// case for each operation of the interface, e.g. `operation::descriptor(descriptor-operation::open-at)`. +fn operation(resolve: &Resolve) -> String { + let latch = interface(resolve, "componentized", "filesystem", "latch"); + let TypeDefKind::Variant(operation) = &resolve.types[latch.types["operation"]].kind else { + panic!("componentized:filesystem/latch#operation must be a variant"); + }; + + let mut interfaces = vec![]; + let mut operations = vec![]; + let mut arms = String::new(); + for interface_case in &operation.cases { + let Some(Type::Id(id)) = interface_case.ty else { + panic!("operation case `{}` must have a variant payload", interface_case.name); + }; + let typedef = &resolve.types[id]; + let TypeDefKind::Variant(variant) = &typedef.kind else { + panic!("operation case `{}` must have a variant payload", interface_case.name); + }; + let interface_name = &interface_case.name; + let interface_ident = interface_name.to_upper_camel_case(); + let variant_ident = typedef.name.as_deref().expect("a named variant").to_upper_camel_case(); + interfaces.push(format!("{interface_name:?}")); + writeln!(arms, " Operation::{interface_ident}(operation) => match operation {{").unwrap(); + for case in &variant.cases { + let name = format!("{interface_name}.{}", case.name); + let ident = case.name.to_upper_camel_case(); + let payload = if case.ty.is_some() { "(_)" } else { "" }; + writeln!(arms, " {variant_ident}::{ident}{payload} => {name:?},").unwrap(); + operations.push(format!("{name:?}")); + } + writeln!(arms, " }},").unwrap(); + } + + format!( + "/// The name of the operation, the interface and operation cases of the latch WIT, e.g.\n\ + /// `descriptor.open-at`.\n\ + pub fn operation_name(operation: &Operation) -> &'static str {{\n \ + match operation {{\n{arms} }}\n\ + }}\n\n\ + /// The name of every interface of the operations, e.g. `descriptor`.\n\ + pub const INTERFACE_NAMES: &[&str] = &[{interfaces}];\n\n\ + /// The name of every operation, e.g. `descriptor.open-at`.\n\ + pub const OPERATION_NAMES: &[&str] = &[{operations}];\n", + interfaces = interfaces.join(", "), + operations = operations.join(", "), + ) +} diff --git a/crates/filesystem-latch/src/lib.rs b/crates/filesystem-latch/src/lib.rs new file mode 100644 index 0000000..91ceb93 --- /dev/null +++ b/crates/filesystem-latch/src/lib.rs @@ -0,0 +1,140 @@ +//! Bindings and helpers for latch components, which implement the `latch` world. +//! +//! The bindings are generated once here and shared by every latch component. A latch implements +//! [`Latch`] and exports it with [`export!`]: +//! +//! ```ignore +//! struct MyLatch {} +//! +//! impl filesystem_latch::Latch for MyLatch { +//! // ... +//! } +//! +//! filesystem_latch::export!(MyLatch with_types_in filesystem_latch::bindings); +//! ``` +//! +//! Imports a latch does not use, like `wasi:config/store` or the wrapped latch, are dropped from +//! the built component. + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} + +pub use bindings::exports::componentized::filesystem::latch::{ + Decision, DescriptorLinkAtArgs, DescriptorMetadataHashAtArgs, DescriptorOpenAtArgs, + DescriptorOperation, DescriptorSetTimesAtArgs, DescriptorStatAtArgs, ErrorCode, + FilesystemErrorCode, Guest as Latch, Operation, PreopensOperation, +}; +pub use bindings::wasi::filesystem::types::{DescriptorFlags, OpenFlags, PathFlags}; + +/// Log a message from a latch with `wasi:logging`. +pub fn log(level: bindings::wasi::logging::logging::Level, message: &str) { + bindings::wasi::logging::logging::log(level, "componentized-latch", message); +} + +/// Log a critical message from a latch, formatted like `format!`. +#[macro_export] +macro_rules! critical { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Critical, &format!($($arg)*)) + }; +} + +/// Log a warning from a latch, formatted like `format!`. +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Warn, &format!($($arg)*)) + }; +} + +/// Load the latch's config from `wasi:config/store` and parse it. +/// +/// When the config cannot be read or parsed, the cause is logged and an `invalid-config` error +/// naming the latch is returned. Parse errors describe the offending entry, e.g. +/// `KEY= VALUE= ERROR=`. +pub fn load_config( + latch_name: &str, + parse: impl FnOnce(Vec<(String, String)>) -> Result, +) -> Result { + use bindings::wasi::config::store; + + store::get_all() + .map_err(|err| match err { + store::Error::Upstream(message) => format!("ERROR=upstream: {message}"), + store::Error::Io(message) => format!("ERROR=io: {message}"), + }) + .and_then(parse) + .map_err(|message| { + critical!("Invalid config LATCH={latch_name} {message}"); + ErrorCode::InvalidConfig(latch_name.to_string()) + }) +} + +/// The reason to deny with, configured with a `reason` key, `None` when the value is empty. Any +/// value that is not the name of an `error-code` case, e.g. `access` or `no-entry`, is `other` +/// with the value as the message. +pub fn reason(value: String) -> Option { + match value.as_str() { + "" => None, + "other" => Some(FilesystemErrorCode::Other(None)), + name => Some(error_code_from_name(name).unwrap_or(FilesystemErrorCode::Other(Some(value)))), + } +} + +include!(concat!(env!("OUT_DIR"), "/error_code.rs")); +include!(concat!(env!("OUT_DIR"), "/operation.rs")); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operations_are_named_by_interface() { + assert_eq!(INTERFACE_NAMES, ["preopens", "descriptor"]); + assert!(OPERATION_NAMES.contains(&"preopens.get-directories")); + assert!(OPERATION_NAMES.contains(&"preopens.get-directories-item")); + assert!(OPERATION_NAMES.contains(&"descriptor.open-at")); + assert!(OPERATION_NAMES.contains(&"descriptor.read-directory-entry")); + assert!(OPERATION_NAMES.iter().all(|name| { + INTERFACE_NAMES + .iter() + .any(|interface| name.starts_with(&format!("{interface}."))) + })); + } + + #[test] + fn reasons_are_parsed_by_name() { + for (name, code) in [ + ("not-permitted", FilesystemErrorCode::NotPermitted), + ("access", FilesystemErrorCode::Access), + ("cross-device", FilesystemErrorCode::CrossDevice), + ("illegal-byte-sequence", FilesystemErrorCode::IllegalByteSequence), + ("other", FilesystemErrorCode::Other(None)), + ( + "custom-reason", + FilesystemErrorCode::Other(Some("custom-reason".to_string())), + ), + ] { + assert_eq!( + format!("{:?}", reason(name.to_string())), + format!("{:?}", Some(code)), + "{name}" + ); + } + assert!(reason(String::new()).is_none()); + } +} diff --git a/crates/test-harness/Cargo.toml b/crates/test-harness/Cargo.toml index 12d41de..e019f53 100644 --- a/crates/test-harness/Cargo.toml +++ b/crates/test-harness/Cargo.toml @@ -7,5 +7,6 @@ publish = false [dependencies] tokio = { workspace = true } +wac-graph = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index 73ed088..4b9e8d0 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -1,8 +1,11 @@ //! Test harness for the filesystem components. //! //! A [`Harness`] instantiates a component (`target/components/*/*.wasm`) with real directories -//! preopened from the host by wasmtime-wasi, values for `wasi:config/store`, and captured -//! `wasi:logging` output. Tests call the component's exported `wasi:filesystem` interfaces. +//! preopened from the host by wasmtime-wasi, a scripted [`HostLatch`], values for +//! `wasi:config/store`, and captured `wasi:logging` output. Latch components from +//! `target/components/` can be installed in place of, or alongside, the host latch, they are +//! composed into the component before it is instantiated. Tests call the component's exported +//! `wasi:filesystem` interfaces. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -12,6 +15,10 @@ use std::sync::{Arc, Mutex}; use std::task::Poll; use tokio::sync::{mpsc, oneshot}; +use wac_graph::{ + CompositionGraph, EncodeOptions, + types::{Package, Types}, +}; use wasmtime::component::{ Accessor, Component, FutureConsumer, FutureReader, HasSelf, Lift, Linker, ResourceTable, Source, StreamConsumer, StreamReader, StreamResult, @@ -23,6 +30,9 @@ use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use crate::bindings::exports::wasi::filesystem::{preopens, types}; use crate::bindings::wasi::config::store; use crate::bindings::wasi::logging::logging; +use crate::latch_bindings::componentized::filesystem::latch::{ + self, DescriptorOperation, Operation, PreopensOperation, +}; pub mod bindings { wasmtime::component::bindgen!({ @@ -43,10 +53,34 @@ pub mod bindings { }); } +/// The latch the test subject imports, generated apart from [`bindings`], its operations borrow +/// host descriptors, which aren't comparable. +pub mod latch_bindings { + wasmtime::component::bindgen!({ + path: "../../components/wit", + inline: " + package componentized:test-harness; + + world latch { + import componentized:filesystem/latch@0.3.0-dev; + } + ", + world: "componentized:test-harness/latch", + require_store_data_send: true, + with: { + "wasi:filesystem": wasmtime_wasi::p3::bindings::filesystem, + "wasi:clocks": wasmtime_wasi::p3::bindings::clocks, + }, + }); +} + pub use bindings::exports::wasi::filesystem::types::{ DescriptorFlags, DescriptorStat, DescriptorType, ErrorCode, OpenFlags, PathFlags, }; pub use bindings::wasi::logging::logging::Level; +pub use latch_bindings::componentized::filesystem::latch::{ + Decision, ErrorCode as LatchErrorCode, FilesystemErrorCode, +}; pub use wasmtime_wasi::FsPerms as Perms; /// Receive the items written to a guest stream on a channel. @@ -141,36 +175,53 @@ pub fn component_path(name: &str) -> PathBuf { workspace_dir().join(format!("target/components/{name}/{name}.wasm")) } -/// Rebuild the named component in `target/components/` with make, unless it was already built by -/// this process. -fn ensure_built(name: &str) -> Result<()> { +/// Rebuild the named components in `target/components/` with make, unless they were already built +/// by this process. +fn ensure_built(names: &[&str]) -> Result<()> { static BUILT: Mutex>> = Mutex::new(None); // hold the lock while building so concurrent tests don't run make over each other let mut built = BUILT.lock().unwrap_or_else(|err| err.into_inner()); let built = built.get_or_insert_with(HashSet::new); - if built.contains(name) { + let targets: Vec = names + .iter() + .filter(|name| !built.contains(**name)) + .map(|name| format!("target/components/{name}/{name}.wasm")) + .collect(); + if targets.is_empty() { return Ok(()); } - let target = format!("target/components/{name}/{name}.wasm"); let output = Command::new("make") .arg("-C") .arg(workspace_dir()) - .arg(&target) + .args(&targets) .output() .context("failed to run make")?; if !output.status.success() { bail!( - "failed to build {target}:\n{}{}", + "failed to build {}:\n{}{}", + targets.join(" "), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } - built.insert(name.to_string()); + built.extend(names.iter().map(|name| name.to_string())); Ok(()) } +/// An operation the host latch was asked to authorize. +/// +/// Resource handles are not retained, only the operation name and a debug rendering of its +/// arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authorization { + /// Operation name, e.g. `descriptor.open-at` + pub operation: String, + /// Debug rendering of the operation arguments, excluding the descriptor operated on + pub args: String, +} + /// A message logged by the test subject via `wasi:logging`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogEntry { @@ -179,13 +230,130 @@ pub struct LogEntry { pub message: String, } +impl LogEntry { + /// A trace message logged by the test subject. + pub fn trace(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Trace, context, message) + } + + /// A warning message logged by the test subject. + pub fn warn(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Warn, context, message) + } + + /// An error message logged by the test subject. + pub fn error(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Error, context, message) + } + + /// A critical message logged by the test subject. + pub fn critical(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Critical, context, message) + } + + fn log(level: Level, context: impl Into, message: impl Into) -> Self { + Self { + level, + context: context.into(), + message: message.into(), + } + } +} + +type Policy = dyn FnMut(&Authorization) -> Result + Send; + +/// Scripted latch implemented by the host. +/// +/// It is the test subject's latch unless latch components are installed, see [`Harness::latch`]. +pub struct HostLatch { + policy: Box, + fail_observe: bool, +} + +impl HostLatch { + /// Defer every operation. + pub fn defer() -> Self { + Self::new(|_| Ok(Decision::Deferred)) + } + + /// Deny the named operations with `access`, defer the rest. + pub fn deny(operations: &[&str]) -> Self { + let operations: Vec = operations.iter().map(|s| s.to_string()).collect(); + Self::new(move |auth| { + if operations.contains(&auth.operation) { + Ok(Decision::Denied(FilesystemErrorCode::Access)) + } else { + Ok(Decision::Deferred) + } + }) + } + + /// Fail the named operations with a latch error, defer the rest. + pub fn error(operations: &[&str], message: &str) -> Self { + let operations: Vec = operations.iter().map(|s| s.to_string()).collect(); + let message = message.to_string(); + Self::new(move |auth| { + if operations.contains(&auth.operation) { + Err(LatchErrorCode::Other(Some(message.clone()))) + } else { + Ok(Decision::Deferred) + } + }) + } + + /// Decide each operation with a custom policy. + pub fn new( + policy: impl FnMut(&Authorization) -> Result + Send + 'static, + ) -> Self { + Self { + policy: Box::new(policy), + fail_observe: false, + } + } + + /// Fail every `observe-decision` call. + pub fn fail_observe(mut self) -> Self { + self.fail_observe = true; + self + } +} + +/// A decision the host latch was told about with `observe-decision`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Observation { + /// Operation name, e.g. `descriptor.open-at` + pub operation: String, + /// Whether the final decision denied the operation + pub denied: bool, +} + /// Observations recorded while the test subject runs. #[derive(Clone, Default)] pub struct Recorder { + authorizations: Arc>>, + observations: Arc>>, logs: Arc>>, } impl Recorder { + /// Operations the host latch was asked to authorize, in order. + pub fn authorizations(&self) -> Vec { + self.authorizations.lock().unwrap().clone() + } + + /// Names of the operations the host latch was asked to authorize, in order. + pub fn operations(&self) -> Vec { + self.authorizations() + .into_iter() + .map(|a| a.operation) + .collect() + } + + /// Decisions the host latch observed, in order. + pub fn observations(&self) -> Vec { + self.observations.lock().unwrap().clone() + } + /// Messages logged by the test subject, in order. pub fn logs(&self) -> Vec { self.logs.lock().unwrap().clone() @@ -195,6 +363,7 @@ impl Recorder { pub struct Ctx { wasi: WasiCtx, table: ResourceTable, + latch: HostLatch, config: Vec<(String, String)>, recorder: Recorder, } @@ -208,6 +377,143 @@ impl WasiView for Ctx { } } +impl latch::Host for Ctx { + fn authorize(&mut self, operation: Operation) -> Result { + let authorization = describe(&operation); + self.recorder + .authorizations + .lock() + .unwrap() + .push(authorization.clone()); + (self.latch.policy)(&authorization) + } + + fn observe_decision( + &mut self, + decision: Decision, + operation: Operation, + ) -> Result<(), LatchErrorCode> { + self.recorder + .observations + .lock() + .unwrap() + .push(Observation { + operation: describe(&operation).operation, + denied: matches!(decision, Decision::Denied(_)), + }); + match self.latch.fail_observe { + true => Err(LatchErrorCode::ObservationFailed("host".to_string())), + false => Ok(()), + } + } +} + +/// Define the host latch under a named import, e.g. `latch1` of a `latch-n` component. +fn add_named_latch_to_linker(linker: &mut Linker, name: &str) -> Result<()> { + let mut instance = linker.instance(name)?; + instance.func_wrap( + "authorize", + |mut store: StoreContextMut<'_, Ctx>, (operation,): (Operation,)| { + Ok((latch::Host::authorize(store.data_mut(), operation),)) + }, + )?; + instance.func_wrap( + "observe-decision", + |mut store: StoreContextMut<'_, Ctx>, (decision, operation): (Decision, Operation)| { + Ok((latch::Host::observe_decision( + store.data_mut(), + decision, + operation, + ),)) + }, + )?; + Ok(()) +} + +fn describe(operation: &Operation) -> Authorization { + let (operation, args) = match operation { + Operation::Preopens(op) => match op { + PreopensOperation::GetDirectories => ("preopens.get-directories", String::new()), + PreopensOperation::GetDirectoriesItem((_, path)) => { + ("preopens.get-directories-item", format!("{path:?}")) + } + }, + Operation::Descriptor(op) => match op { + DescriptorOperation::ReadViaStream((_, _, args)) => { + ("descriptor.read-via-stream", format!("{args:?}")) + } + DescriptorOperation::WriteViaStream((_, _, args)) => { + ("descriptor.write-via-stream", format!("{args:?}")) + } + DescriptorOperation::AppendViaStream(_) => { + ("descriptor.append-via-stream", String::new()) + } + DescriptorOperation::Advise((_, _, args)) => ("descriptor.advise", format!("{args:?}")), + DescriptorOperation::SyncData(_) => ("descriptor.sync-data", String::new()), + DescriptorOperation::GetFlags(_) => ("descriptor.get-flags", String::new()), + DescriptorOperation::GetType(_) => ("descriptor.get-type", String::new()), + DescriptorOperation::SetSize((_, _, args)) => { + ("descriptor.set-size", format!("{args:?}")) + } + DescriptorOperation::SetTimes((_, _, args)) => { + ("descriptor.set-times", format!("{args:?}")) + } + DescriptorOperation::ReadDirectory(_) => ("descriptor.read-directory", String::new()), + DescriptorOperation::ReadDirectoryEntry((_, _, entry)) => { + ("descriptor.read-directory-entry", format!("{entry:?}")) + } + DescriptorOperation::Sync(_) => ("descriptor.sync", String::new()), + DescriptorOperation::CreateDirectoryAt((_, _, args)) => { + ("descriptor.create-directory-at", format!("{args:?}")) + } + DescriptorOperation::Stat(_) => ("descriptor.stat", String::new()), + DescriptorOperation::StatAt((_, _, args)) => { + ("descriptor.stat-at", format!("{args:?}")) + } + DescriptorOperation::SetTimesAt((_, _, args)) => { + ("descriptor.set-times-at", format!("{args:?}")) + } + DescriptorOperation::LinkAt((_, _, args)) => ( + "descriptor.link-at", + format!( + "old_path_flags: {:?}, old_path: {:?}, new_path: {:?}", + args.old_path_flags, args.old_path, args.new_path + ), + ), + DescriptorOperation::OpenAt((_, _, args)) => { + ("descriptor.open-at", format!("{args:?}")) + } + DescriptorOperation::ReadlinkAt((_, _, args)) => { + ("descriptor.readlink-at", format!("{args:?}")) + } + DescriptorOperation::RemoveDirectoryAt((_, _, args)) => { + ("descriptor.remove-directory-at", format!("{args:?}")) + } + DescriptorOperation::RenameAt((_, _, args)) => ( + "descriptor.rename-at", + format!( + "old_path: {:?}, new_path: {:?}", + args.old_path, args.new_path + ), + ), + DescriptorOperation::SymlinkAt((_, _, args)) => { + ("descriptor.symlink-at", format!("{args:?}")) + } + DescriptorOperation::UnlinkFileAt((_, _, args)) => { + ("descriptor.unlink-file-at", format!("{args:?}")) + } + DescriptorOperation::MetadataHash(_) => ("descriptor.metadata-hash", String::new()), + DescriptorOperation::MetadataHashAt((_, _, args)) => { + ("descriptor.metadata-hash-at", format!("{args:?}")) + } + }, + }; + Authorization { + operation: operation.to_string(), + args, + } +} + impl store::Host for Ctx { fn get(&mut self, key: String) -> Result, store::Error> { Ok(self @@ -236,6 +542,8 @@ impl logging::Host for Ctx { /// Builds a subject instance for a test. pub struct Harness { subject: String, + latches: Vec, + host_latch: Option, config: Vec<(String, String)>, preopens: Vec<(PathBuf, String, FsPerms)>, } @@ -245,11 +553,31 @@ impl Harness { pub fn new(component_name: &str) -> Self { Self { subject: component_name.to_string(), + latches: vec![], + host_latch: None, config: vec![], preopens: vec![], } } + /// Install a latch component from `target/components/`. + /// + /// Without latch components the host latch is the test subject's latch. A single latch + /// component replaces it, unless the latch imports a latch, then it wraps the host latch. + /// Otherwise several latches, including the host latch when one is set with + /// [`Harness::host_latch`], are aggregated with the `latch-n` component of the same size, + /// in the order installed with the host latch last. + pub fn latch(mut self, name: &str) -> Self { + self.latches.push(name.to_string()); + self + } + + /// Replace the default deferring host latch, it is aggregated with any latch components. + pub fn host_latch(mut self, latch: HostLatch) -> Self { + self.host_latch = Some(latch); + self + } + /// Add a `wasi:config/store` value. pub fn config(mut self, key: &str, value: &str) -> Self { self.config.push((key.to_string(), value.to_string())); @@ -271,22 +599,25 @@ impl Harness { self } - /// Instantiate the test subject. + /// Compose and instantiate the test subject. pub async fn build(self) -> Result { - ensure_built(&self.subject)?; - let mut config = Config::new(); config.wasm_component_model_async(true); // componentized-rt's background executor runs on a component model thread config.wasm_component_model_threading(true); + // wac-graph encodes the named `latch{i}` imports of an aggregated latch-n with `implements` + config.wasm_component_model_implements(true); let engine = Engine::new(&config)?; - let path = component_path(&self.subject); - let component = Component::from_file(&engine, &path) - .with_context(|| format!("failed to read {}", path.display()))?; + let bytes = self.compose()?; + let component = Component::new(&engine, &bytes)?; let mut linker = Linker::new(&engine); wasmtime_wasi::p3::add_to_linker(&mut linker)?; + latch::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; + for slot in 0..LATCH_N_MAX { + add_named_latch_to_linker(&mut linker, &format!("latch{slot}"))?; + } store::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; logging::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; @@ -303,6 +634,7 @@ impl Harness { Ctx { wasi: wasi.build(), table: ResourceTable::new(), + latch: self.host_latch.unwrap_or_else(HostLatch::defer), config: self.config, recorder: recorder.clone(), }, @@ -325,6 +657,91 @@ impl Harness { } } +impl Harness { + fn compose(&self) -> Result> { + let mut names = vec![self.subject.as_str()]; + names.extend(self.latches.iter().map(String::as_str)); + ensure_built(&names)?; + + let read = |name: &str| { + let path = component_path(name); + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display())) + }; + + let bytes = read(&self.subject)?; + let latch = match self.latches.as_slice() { + [] => return Ok(bytes), + // a latch that wraps another latch wraps the host latch, its import is left for the host + [latch] if self.host_latch.is_none() || imports_latch(&read(latch)?)? => read(latch)?, + latches => { + // the host latch takes the last slot of latch-n, left unsatisfied it is imported + let slots = latches.len() + usize::from(self.host_latch.is_some()); + if slots > LATCH_N_MAX { + bail!("at most {LATCH_N_MAX} latches can be aggregated, got {slots}"); + } + let latch_n = format!("latch-n{slots}"); + ensure_built(&[&latch_n])?; + let latches = latches + .iter() + .map(|name| read(name)) + .collect::>>()?; + aggregate(read(&latch_n)?, latches)? + } + }; + plug(&self.subject, bytes, latch) + } +} + +/// The most latches a `latch-n` component aggregates. +const LATCH_N_MAX: usize = 5; + +const LATCH_INTERFACE: &str = "componentized:filesystem/latch@0.3.0-dev"; + +/// Whether the latch component imports a latch, which it wraps. +fn imports_latch(latch: &[u8]) -> Result { + let mut types = Types::default(); + let package = Package::from_bytes("test:latch", None, latch.to_vec(), &mut types) + .map_err(|err| format_err!("{err:#}"))?; + Ok(types[package.ty()].imports.contains_key(LATCH_INTERFACE)) +} + +/// Satisfy the leading `latch{i}` imports of a `latch-n` component with the latches, any +/// remaining slot is left for the host. +fn aggregate(latch_n: Vec, latches: Vec>) -> Result> { + let mut graph = CompositionGraph::new(); + let latch_n = Package::from_bytes("test:latch-n", None, latch_n, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch_n = graph.register_package(latch_n)?; + let latch_n = graph.instantiate(latch_n); + for (slot, latch) in latches.into_iter().enumerate() { + let latch = + Package::from_bytes(&format!("test:latch{slot}"), None, latch, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch = graph.register_package(latch)?; + let latch = graph.instantiate(latch); + let export = graph.alias_instance_export(latch, LATCH_INTERFACE)?; + graph.set_instantiation_argument(latch_n, &format!("latch{slot}"), export)?; + } + let export = graph.alias_instance_export(latch_n, LATCH_INTERFACE)?; + graph.export(export, LATCH_INTERFACE)?; + Ok(graph.encode(EncodeOptions::default())?) +} + +/// Satisfy the socket's imports with the plug's exports. +fn plug(name: &str, socket: Vec, plug: Vec) -> Result> { + let mut graph = CompositionGraph::new(); + let socket = Package::from_bytes("test:socket", None, socket, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let socket = graph.register_package(socket)?; + let plug = Package::from_bytes("test:plug", None, plug, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let plug = graph.register_package(plug)?; + if let Err(err) = wac_graph::plug(&mut graph, vec![plug], socket) { + bail!("failed to plug latch into {name}: {err}"); + } + Ok(graph.encode(EncodeOptions::default())?) +} + /// The test subject's exported interfaces. pub struct Exports { types: types::Guest, @@ -351,7 +768,7 @@ pub struct TestSubject { } impl TestSubject { - /// Observations recorded by the logger. + /// Observations recorded by the host latch and logger. pub fn recorder(&self) -> Recorder { self.recorder.clone() } diff --git a/lib/tests/.gitignore b/lib/tests/.gitignore new file mode 100644 index 0000000..902f891 --- /dev/null +++ b/lib/tests/.gitignore @@ -0,0 +1,2 @@ +*.md +*.wasm diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 6e46084..7b30b30 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -15,11 +15,13 @@ path = "lib.rs" # the binaries each tool installs, the Makefile runs each by path from a variable named after it, # e.g. `$(WASM_TOOLS)` for `wasm-tools` [package.metadata.bins] +componentized-static-config-cli = ["static-config"] wac-cli = ["wac"] wasm-tools = ["wasm-tools"] wkg = ["wkg"] [dependencies] +componentized-static-config-cli = "=0.3.0" wac-cli = "=0.12.0" wasm-tools = "=1.261.0" wkg = "=0.16.1" diff --git a/wit/latch.wit b/wit/latch.wit new file mode 100644 index 0000000..5084643 --- /dev/null +++ b/wit/latch.wit @@ -0,0 +1,303 @@ +/// Access control for `wasi:filesystem`. +/// +/// A gate consults a latch before performing each filesystem operation. The latch either defers, +/// raising no objection, or denies the operation. A latch never grants an operation, an +/// operation proceeds when the latch defers. Several latches can be aggregated into one, the +/// operation is denied when any of them denies it. +/// +/// Deciding and acting on a decision are separate steps. `authorize` decides without side +/// effects, then the gate reports the final decision to `observe-decision`, where a latch updates +/// any state it keeps. A latch aggregated with others may not be asked to authorize an operation +/// another latch already denied, but it always observes the final decision. +@since(version = 0.3.0-dev) +interface latch { + @since(version = 0.3.0-dev) + use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + + /// The path of a descriptor, the directory or file an operation is performed on, and that the + /// relative path arguments of the operation are resolved against. + /// + /// A preopen is labeled with its name, e.g. `/` or `.`. A descriptor opened with `open-at` is + /// labeled with the path it was opened at, joined to the path of the descriptor it was opened + /// from: `.` and `..` are applied, and symlinks are followed where the host follows them. + /// Path arguments of an operation are passed as given by the caller, relative to the + /// descriptor's path. + @since(version = 0.3.0-dev) + type path = string; + + /// Arguments to wasi:filesystem/types#descriptor.read-via-stream. + @since(version = 0.3.0-dev) + record descriptor-read-via-stream-args { + /// The offset in the file to start reading at. + offset: filesize, + } + + /// Arguments to wasi:filesystem/types#descriptor.write-via-stream. + @since(version = 0.3.0-dev) + record descriptor-write-via-stream-args { + /// The offset in the file to start writing at. + offset: filesize, + } + + /// Arguments to wasi:filesystem/types#descriptor.advise. + @since(version = 0.3.0-dev) + record descriptor-advise-args { + /// The offset of the range the advice applies to. + offset: filesize, + /// The length of the range the advice applies to. + length: filesize, + /// The advice for the range. + advice: advice, + } + + /// Arguments to wasi:filesystem/types#descriptor.set-size. + @since(version = 0.3.0-dev) + record descriptor-set-size-args { + /// The new size of the file. + size: filesize, + } + + /// Arguments to wasi:filesystem/types#descriptor.set-times. + @since(version = 0.3.0-dev) + record descriptor-set-times-args { + /// The new access time. + data-access-timestamp: new-timestamp, + /// The new modification time. + data-modification-timestamp: new-timestamp, + } + + /// Arguments to wasi:filesystem/types#descriptor.create-directory-at. + @since(version = 0.3.0-dev) + record descriptor-create-directory-at-args { + /// The directory to create, relative to the descriptor's path. + path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.stat-at. + @since(version = 0.3.0-dev) + record descriptor-stat-at-args { + /// Whether a symlink at the last component of the path is followed. + path-flags: path-flags, + /// The file or directory to stat, relative to the descriptor's path. + path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.set-times-at. + @since(version = 0.3.0-dev) + record descriptor-set-times-at-args { + /// Whether a symlink at the last component of the path is followed. + path-flags: path-flags, + /// The file or directory to change, relative to the descriptor's path. + path: path, + /// The new access time. + data-access-timestamp: new-timestamp, + /// The new modification time. + data-modification-timestamp: new-timestamp, + } + + /// Arguments to wasi:filesystem/types#descriptor.link-at. + @since(version = 0.3.0-dev) + record descriptor-link-at-args { + /// Whether a symlink at the last component of the old path is followed. + old-path-flags: path-flags, + /// The existing file to link to, relative to the descriptor's path. + old-path: path, + /// The directory the new path is relative to, a latch may query it. + new-descriptor: borrow, + /// The link to create, relative to the new descriptor. + new-path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.open-at. + @since(version = 0.3.0-dev) + record descriptor-open-at-args { + /// Whether a symlink at the last component of the path is followed. + path-flags: path-flags, + /// The file or directory to open, relative to the descriptor's path. + path: path, + /// How the file or directory is opened, e.g. whether it is created or truncated. + open-flags: open-flags, + /// What the opened descriptor may be used for, e.g. reading, writing or changing a + /// directory. + %flags: descriptor-flags, + } + + /// Arguments to wasi:filesystem/types#descriptor.readlink-at. + @since(version = 0.3.0-dev) + record descriptor-readlink-at-args { + /// The symlink to read, relative to the descriptor's path. + path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.remove-directory-at. + @since(version = 0.3.0-dev) + record descriptor-remove-directory-at-args { + /// The directory to remove, relative to the descriptor's path. + path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.rename-at. + @since(version = 0.3.0-dev) + record descriptor-rename-at-args { + /// The file or directory to rename, relative to the descriptor's path. + old-path: path, + /// The directory the new path is relative to, a latch may query it. + new-descriptor: borrow, + /// The new path, relative to the new descriptor. + new-path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.symlink-at. + @since(version = 0.3.0-dev) + record descriptor-symlink-at-args { + /// The contents of the symlink, the path it points to. It is not resolved by the gate. + old-path: path, + /// The symlink to create, relative to the descriptor's path. + new-path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.unlink-file-at. + @since(version = 0.3.0-dev) + record descriptor-unlink-file-at-args { + /// The file to unlink, relative to the descriptor's path. + path: path, + } + + /// Arguments to wasi:filesystem/types#descriptor.metadata-hash-at. + @since(version = 0.3.0-dev) + record descriptor-metadata-hash-at-args { + /// Whether a symlink at the last component of the path is followed. + path-flags: path-flags, + /// The file or directory to hash, relative to the descriptor's path. + path: path, + } + + /// Operations of the wasi:filesystem/preopens interface. + @since(version = 0.3.0-dev) + variant preopens-operation { + /// Authorized before the preopens are returned. A denial returns no preopens. + get-directories, + /// Authorized for each preopen, with its name. A denied preopen is left out of the + /// preopens returned, the other preopens are still returned. + get-directories-item(tuple, path>), + } + + /// Operations of the wasi:filesystem/types#descriptor resource. + /// + /// The borrowed descriptor is the descriptor the operation is performed on, followed by its + /// path, a latch may query the descriptor, for example for its type or flags. A denial fails + /// the operation with the denial's error code, unless noted otherwise. + @since(version = 0.3.0-dev) + variant descriptor-operation { + /// Authorized before reading a file. A denial returns an empty stream, the future + /// resolves to the error code. + read-via-stream(tuple, path, descriptor-read-via-stream-args>), + /// Authorized before writing a file. A denial reads nothing from the stream, the future + /// resolves to the error code. + write-via-stream(tuple, path, descriptor-write-via-stream-args>), + /// Authorized before appending to a file. A denial reads nothing from the stream, the + /// future resolves to the error code. + append-via-stream(tuple, path>), + /// Authorized before giving advice for a file. + advise(tuple, path, descriptor-advise-args>), + /// Authorized before syncing the data of a file. + sync-data(tuple, path>), + /// Authorized before reading the flags of a descriptor. + get-flags(tuple, path>), + /// Authorized before reading the type of a descriptor. + get-type(tuple, path>), + /// Authorized before changing the size of a file. + set-size(tuple, path, descriptor-set-size-args>), + /// Authorized before changing the times of a file or directory. + set-times(tuple, path, descriptor-set-times-args>), + /// Authorized before reading a directory. A denial returns an empty stream, the future + /// resolves to the error code. + read-directory(tuple, path>), + /// Authorized for each entry of a directory being read. A denied entry is left out of the + /// stream, the other entries are still returned. + read-directory-entry(tuple, path, directory-entry>), + /// Authorized before syncing the data and metadata of a file or directory. + sync(tuple, path>), + /// Authorized before creating a directory. + create-directory-at(tuple, path, descriptor-create-directory-at-args>), + /// Authorized before reading the attributes of a file or directory. + stat(tuple, path>), + /// Authorized before reading the attributes of a file or directory at a path. + stat-at(tuple, path, descriptor-stat-at-args>), + /// Authorized before changing the times of a file or directory at a path. + set-times-at(tuple, path, descriptor-set-times-at-args>), + /// Authorized before creating a hard link. + link-at(tuple, path, descriptor-link-at-args>), + /// Authorized before opening a file or directory. + open-at(tuple, path, descriptor-open-at-args>), + /// Authorized before reading the contents of a symlink. + readlink-at(tuple, path, descriptor-readlink-at-args>), + /// Authorized before removing a directory. + remove-directory-at(tuple, path, descriptor-remove-directory-at-args>), + /// Authorized before renaming a file or directory. + rename-at(tuple, path, descriptor-rename-at-args>), + /// Authorized before creating a symlink. + symlink-at(tuple, path, descriptor-symlink-at-args>), + /// Authorized before unlinking a file. + unlink-file-at(tuple, path, descriptor-unlink-file-at-args>), + /// Authorized before hashing the metadata of a file or directory. + metadata-hash(tuple, path>), + /// Authorized before hashing the metadata of a file or directory at a path. + metadata-hash-at(tuple, path, descriptor-metadata-hash-at-args>), + } + + /// Operations of the wasi:filesystem package a gate asks a latch to authorize. + @since(version = 0.3.0-dev) + variant operation { + /// An operation of wasi:filesystem/preopens. + preopens(preopens-operation), + /// An operation on a wasi:filesystem/types#descriptor. + descriptor(descriptor-operation), + } + + /// Whether the operation is allowed to proceed. + @since(version = 0.3.0-dev) + variant decision { + /// The latch raises no objection, the operation proceeds unless another latch denies it. + deferred, + /// The operation must not proceed. The error code is returned to the caller for + /// operations that fail. For items that are left out instead, a preopen or a directory + /// entry, it is only logged. + denied(filesystem-error-code), + } + + /// The name of a latch component, identifying which latch reported an error. + @since(version = 0.3.0-dev) + type latch-name = string; + + /// A latch was unable to decide or observe. The gate fails the operation, a directory entry + /// fails the directory read. Getting the preopens can't fail, the gate traps instead. + @since(version = 0.3.0-dev) + variant error-code { + /// The latch's configuration is invalid, the cause is logged by the latch. + invalid-config(latch-name), + /// The latch was unable to act on the final decision. + observation-failed(latch-name), + /// Any other error. + other(option), + } + + /// Decide whether the operation may proceed. + /// + /// Must not have side effects. When latches are aggregated, a latch is not asked about an + /// operation another latch already denied, so state changed here could disagree with what + /// actually happened. Act on decisions in `observe-decision` instead. + @since(version = 0.3.0-dev) + authorize: func(operation: operation) -> result; + + /// Observe the final decision for an operation, after `authorize` returned a decision. + /// + /// Called for both deferred and denied operations, including operations this latch was not + /// asked to authorize because an aggregated latch denied them first. The final decision is + /// what the gate enforces, so this is where a latch updates any state it keeps. Not called + /// when `authorize` returned an error. + /// + /// An error fails the operation, since the latch could not act on the decision. + @since(version = 0.3.0-dev) + observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; +} diff --git a/wit/worlds.wit b/wit/worlds.wit index b4f47f1..5840cad 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -1,6 +1,5 @@ package componentized:filesystem@0.3.0-dev; world imports { - import wasi:filesystem/preopens@0.3.0; - import wasi:filesystem/types@0.3.0; + import latch; }