From a430dc1581ed7fcb4b653267bbef779f13f1bedb Mon Sep 17 00:00:00 2001 From: phg Date: Fri, 24 Jul 2026 00:46:07 +0200 Subject: [PATCH] prototype: validate writable Dovecot tag projection Co-Authored-By: Claude --- flake.lock | 44 + flake.nix | 79 ++ prototype/wayfinder-6/Cargo.lock | 509 ++++++++++ prototype/wayfinder-6/Cargo.toml | 12 + prototype/wayfinder-6/README.md | 52 ++ .../1700000001.M2P1.local,S=132,W=138:2,Sa | 6 + .../new/1700000000.M1P1.local,S=186,W=192 | 6 + prototype/wayfinder-6/nix/vm-test.nix | 315 +++++++ prototype/wayfinder-6/src/main.rs | 872 ++++++++++++++++++ 9 files changed, 1895 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 prototype/wayfinder-6/Cargo.lock create mode 100644 prototype/wayfinder-6/Cargo.toml create mode 100644 prototype/wayfinder-6/README.md create mode 100644 prototype/wayfinder-6/fixtures/source/cur/1700000001.M2P1.local,S=132,W=138:2,Sa create mode 100644 prototype/wayfinder-6/fixtures/source/new/1700000000.M1P1.local,S=186,W=192 create mode 100644 prototype/wayfinder-6/nix/vm-test.nix create mode 100644 prototype/wayfinder-6/src/main.rs diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5f9f1db --- /dev/null +++ b/flake.lock @@ -0,0 +1,44 @@ +{ + "nodes": { + "nixpkgs23": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs24": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs23": "nixpkgs23", + "nixpkgs24": "nixpkgs24" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ddad371 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + description = "THROWAWAY Wayfinder #6 Dovecot/FUSE prototype"; + + inputs = { + nixpkgs23.url = "github:NixOS/nixpkgs/nixos-24.05"; + nixpkgs24.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs23, nixpkgs24 }: + let + linuxSystems = [ "x86_64-linux" "aarch64-linux" ]; + forLinuxSystems = nixpkgs24.lib.genAttrs linuxSystems; + makePackage = pkgs: pkgs.rustPlatform.buildRustPackage { + pname = "wayfinder-6-prototype"; + version = "0.0.0"; + src = ./prototype/wayfinder-6; + cargoLock.lockFile = ./prototype/wayfinder-6/Cargo.lock; + doCheck = true; + }; + packagesFor = system: + let + pkgs23 = import nixpkgs23 { inherit system; }; + pkgs24 = import nixpkgs24 { inherit system; }; + in { + inherit pkgs23 pkgs24; + # fuser 0.17 uses edition 2024; build both prototype binaries with + # current Rust while testing each binary against its Dovecot generation. + package23 = makePackage pkgs24; + package24 = makePackage pkgs24; + }; + vmTest = pkgs: package: generation: separator: + import ./prototype/wayfinder-6/nix/vm-test.nix { + inherit pkgs package generation separator; + dovecot = pkgs.dovecot; + }; + checksFor = system: + let built = packagesFor system; + in { + dovecot-2_3-dot = vmTest built.pkgs23 built.package23 "2.3" "."; + dovecot-2_3-slash = vmTest built.pkgs23 built.package23 "2.3" "/"; + dovecot-2_4-dot = vmTest built.pkgs24 built.package24 "2.4" "."; + dovecot-2_4-slash = vmTest built.pkgs24 built.package24 "2.4" "/"; + }; + runnerFor = runnerPkgs: targetSystem: runnerPkgs.writeShellApplication { + name = "wayfinder-6-vm-test-${targetSystem}"; + text = '' + set -x + nix build -L \ + ${self}#checks.${targetSystem}.dovecot-2_3-dot \ + ${self}#checks.${targetSystem}.dovecot-2_3-slash \ + ${self}#checks.${targetSystem}.dovecot-2_4-dot \ + ${self}#checks.${targetSystem}.dovecot-2_4-slash + ''; + }; + appFor = runnerPkgs: targetSystem: { + type = "app"; + program = "${runnerFor runnerPkgs targetSystem}/bin/wayfinder-6-vm-test-${targetSystem}"; + }; + darwinPkgs = import nixpkgs24 { system = "aarch64-darwin"; }; + darwinAarch64App = appFor darwinPkgs "aarch64-linux"; + in { + packages = forLinuxSystems (system: { + default = (packagesFor system).package24; + }); + checks = forLinuxSystems checksFor; + apps = (forLinuxSystems (system: { + default = appFor (packagesFor system).pkgs24 system; + vm-test = appFor (packagesFor system).pkgs24 system; + })) // { + # Run these from Apple Silicon with a matching Colima builder for aarch64, + # or an x86_64-linux QEMU/TCG builder for the cross-architecture matrix. + aarch64-darwin = { + default = darwinAarch64App; + vm-test-aarch64-linux = darwinAarch64App; + vm-test-x86_64-linux = appFor darwinPkgs "x86_64-linux"; + }; + }; + }; +} diff --git a/prototype/wayfinder-6/Cargo.lock b/prototype/wayfinder-6/Cargo.lock new file mode 100644 index 0000000..2bcb059 --- /dev/null +++ b/prototype/wayfinder-6/Cargo.lock @@ -0,0 +1,509 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fuser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" +dependencies = [ + "bitflags", + "libc", + "log", + "memchr", + "nix", + "num_enum", + "page_size", + "parking_lot", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wayfinder-6-prototype" +version = "0.0.0" +dependencies = [ + "fuser", + "libc", + "tempfile", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/prototype/wayfinder-6/Cargo.toml b/prototype/wayfinder-6/Cargo.toml new file mode 100644 index 0000000..1972b7d --- /dev/null +++ b/prototype/wayfinder-6/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wayfinder-6-prototype" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +fuser = { version = "0.17", default-features = false, features = ["abi-7-31"] } +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/prototype/wayfinder-6/README.md b/prototype/wayfinder-6/README.md new file mode 100644 index 0000000..5276256 --- /dev/null +++ b/prototype/wayfinder-6/README.md @@ -0,0 +1,52 @@ +# Wayfinder 6 prototype + +**PROTOTYPE / THROWAWAY. Do not merge this branch into production.** + +Question: can a Dovecot-visible writable Tag projection preserve global Maildir +message state while rejecting unsupported namespace writes? + +## Verdict + +Yes, with two required boundaries: + +1. Lookups accept only the current canonical Maildir filename. Returning the same + stable inode for stale flag-filename aliases lets Linux collapse Dovecot's + `rename(old, new)` into a no-op before FUSE sees it. Canonical misses make + Dovecot rescan and issue the real flag rename. +2. Dovecot ACLs must remove mailbox-create and mailbox-delete rights (`k`, `x`). + FUSE alone cannot distinguish Message unlinks performed for mailbox deletion + from valid MOVE-out or EXPUNGE unlinks soon enough to reject mailbox deletion + atomically. + +The same filesystem behavior passed against Dovecot 2.3.21.1 and 2.4.4 with +both `.` and `/` namespace separators. The test covers discovery, distinct +projection inodes, reads, global `new -> cur`, shared standard flags, Deleted and +EXPUNGE, COPY/MOVE out, rejected ingress/content/structural writes, external +Source reconciliation, external Dovecot metadata, and UID/UIDVALIDITY stability +across flag changes and restarts. + +## Run + +On an aarch64 Linux host with NixOS VM-test support: + +```sh +nix build -L \ + .#checks.aarch64-linux.dovecot-2_3-dot \ + .#checks.aarch64-linux.dovecot-2_3-slash \ + .#checks.aarch64-linux.dovecot-2_4-dot \ + .#checks.aarch64-linux.dovecot-2_4-slash +``` + +The VM tests can run under QEMU TCG when KVM is unavailable. + +## Deliberate omissions + +This disposable implementation uses polling and fixture-oriented header +parsing. It omits production persistence, direct inotify recovery, SQLite, +scale testing, a service module, full RFC parsing, and Source Dovecot uidlist +locking. + +ACLs constrain normal Dovecot client/storage operations, not privileged local +administrative paths that explicitly ignore ACLs. Production configuration must +also exclude mailbox autocreation and equivalent administrative bypasses in the +Tag namespace. diff --git a/prototype/wayfinder-6/fixtures/source/cur/1700000001.M2P1.local,S=132,W=138:2,Sa b/prototype/wayfinder-6/fixtures/source/cur/1700000001.M2P1.local,S=132,W=138:2,Sa new file mode 100644 index 0000000..53b5e49 --- /dev/null +++ b/prototype/wayfinder-6/fixtures/source/cur/1700000001.M2P1.local,S=132,W=138:2,Sa @@ -0,0 +1,6 @@ +Delivered-To: demo+beta@example.test +X-Wayfinder-Tags: shared +Message-ID: +Subject: Wayfinder fixture two + +second diff --git a/prototype/wayfinder-6/fixtures/source/new/1700000000.M1P1.local,S=186,W=192 b/prototype/wayfinder-6/fixtures/source/new/1700000000.M1P1.local,S=186,W=192 new file mode 100644 index 0000000..f7b8f5d --- /dev/null +++ b/prototype/wayfinder-6/fixtures/source/new/1700000000.M1P1.local,S=186,W=192 @@ -0,0 +1,6 @@ +To: Demo +X-Wayfinder-Tags: shared, team.ops, café, 台北, 100%, .hidden, ~home, INBOX, cur +Message-ID: +Subject: Wayfinder fixture one + +one diff --git a/prototype/wayfinder-6/nix/vm-test.nix b/prototype/wayfinder-6/nix/vm-test.nix new file mode 100644 index 0000000..2767d80 --- /dev/null +++ b/prototype/wayfinder-6/nix/vm-test.nix @@ -0,0 +1,315 @@ +# PROTOTYPE / THROWAWAY. This intentionally combines real Dovecot with synthetic mail. +{ pkgs, package, dovecot, generation, separator }: +let + lib = pkgs.lib; + is24 = generation == "2.4"; + expectedVersion = if is24 then "2.4" else "2.3.21.1"; + prefix = "00_tags${separator}"; + visible = tag: "${prefix}${tag}"; + visibleAlpha = visible "alpha"; + visibleBeta = visible "beta"; + visibleShared = visible "shared"; + location23 = "maildir:/run/tag-view:LAYOUT=fs:CONTROL=/var/lib/dovecot/tag-control/%u:INDEX=/var/lib/dovecot/tag-index/%u"; + globalAcl23 = pkgs.writeText "wayfinder-global-acl" '' + * owner lrwstipea + ''; + config = pkgs.writeText "dovecot-${generation}-${if separator == "." then "dot" else "slash"}.conf" ( + (lib.optionalString is24 '' + dovecot_config_version = 2.4.0 + dovecot_storage_version = 2.4.0 + mail_plugins { + acl = yes + } + acl_driver = vfile + acl_globals_only = yes + '') + '' + protocols = + listen = 127.0.0.1 + base_dir = /run/dovecot + state_dir = /var/lib/dovecot + first_valid_uid = 1000 + mail_uid = test + mail_gid = test + '' + (if is24 then '' + mail_home = /var/lib/mail/%{user} + passdb static { + password = prototype + } + userdb static { + fields { + home = /var/lib/mail/%{user} + uid = test + gid = test + } + } + '' else '' + mail_home = /var/lib/mail/%u + passdb { + driver = static + args = password=prototype + } + userdb { + driver = static + args = uid=test gid=test home=/var/lib/mail/%u + } + '') + (lib.optionalString (!is24) '' + mail_plugins = acl + mail_plugin_dir = ${dovecot}/lib/dovecot + plugin { + acl = vfile:${globalAcl23} + acl_globals_only = yes + } + maildir_very_dirty_syncs = no + mailbox_list_index = no + maildir_copy_with_hardlinks = no + maildir_empty_new = yes + '') + '' + + namespace inbox { + type = private + separator = ${separator} + prefix = + inbox = yes + list = yes + subscriptions = yes + '' + (if is24 then '' + mail_driver = maildir + mail_path = /var/lib/mail/%{user}/Maildir + '' else '' + location = maildir:/var/lib/mail/%u/Maildir + '') + '' + } + + namespace tags { + type = private + separator = ${separator} + prefix = ${prefix} + inbox = no + list = yes + subscriptions = no + '' + (if is24 then '' + mail_driver = maildir + mail_path = /run/tag-view + mailbox_list_layout = fs + mail_control_path = /var/lib/dovecot/tag-control/%{user} + mail_index_path = /var/lib/dovecot/tag-index/%{user} + maildir_very_dirty_syncs = no + mailbox_list_index = no + maildir_copy_with_hardlinks = no + maildir_empty_new = yes + acl owner { + rights = lrwstipea + } + '' else '' + location = ${location23} + list = yes + '') + '' + } + '' + ); +in +assert lib.assertMsg + (lib.hasPrefix expectedVersion dovecot.version) + "expected Dovecot ${expectedVersion}, got ${dovecot.version}"; +let + test = pkgs.testers.nixosTest ({ + name = "wayfinder-6-dovecot-${generation}-${if separator == "." then "dot" else "slash"}"; + + nodes.machine = { ... }: { + boot.kernelModules = [ "fuse" ]; + environment.systemPackages = [ + package + dovecot + pkgs.fuse3 + pkgs.coreutils + pkgs.findutils + pkgs.util-linux + ]; + users.groups.test = { }; + users.groups.dovecot = { }; + users.groups.dovenull = { }; + users.users.test = { + isSystemUser = true; + group = "test"; + uid = 1000; + home = "/var/lib/mail/test"; + createHome = true; + }; + users.users.dovecot = { + isSystemUser = true; + group = "dovecot"; + }; + users.users.dovenull = { + isSystemUser = true; + group = "dovenull"; + }; + system.stateVersion = if is24 then "25.11" else "23.11"; + } // lib.optionalAttrs is24 { + programs.fuse.enable = true; + }; + + testScript = '' + start_all() + + doveadm = "${dovecot}/bin/doveadm -c ${config}" + dovecot_bin = "${dovecot}/sbin/dovecot -c ${config}" + as_test = "runuser -u test --" + + machine.succeed("mkdir -p /run/source/{cur,new,tmp} /run/tag-view /run/dovecot /var/lib/mail/test/Maildir/{cur,new,tmp} /var/lib/dovecot/tag-{control,index}") + machine.succeed("cp -a ${../fixtures/source}/. /run/source/") + machine.succeed("chown -R test:test /run/source /run/tag-view /var/lib/mail/test /var/lib/dovecot/tag-control /var/lib/dovecot/tag-index") + machine.succeed("chmod 0755 /var/lib/mail; chmod 0700 /run/source/{cur,new,tmp} /run/tag-view") + + def mount_projection(): + machine.succeed("chmod 0700 /run/tag-view") + machine.succeed("runuser -u test -- sh -c 'wayfinder-6-prototype /run/source /run/tag-view ${separator} >/tmp/prototype.log 2>&1 & echo $! >/tmp/prototype.pid'") + machine.wait_until_succeeds(as_test + " mountpoint /run/tag-view || (cat /tmp/prototype.log >&2; false)", timeout=60) + + def unmount_projection(): + machine.succeed("runuser -u test -- fusermount3 -u /run/tag-view") + machine.wait_until_fails(as_test + " mountpoint /run/tag-view") + machine.succeed("test ! -e /tmp/prototype.pid || kill $(cat /tmp/prototype.pid) 2>/dev/null || true") + machine.succeed("rm -f /tmp/prototype.pid; chmod 0500 /run/tag-view") + + mount_projection() + machine.succeed(dovecot_bin) + + # Control: the same Dovecot command persists flags in a native Maildir filename. + machine.succeed("cp ${../fixtures/source}/cur/* '/var/lib/mail/test/Maildir/cur/native:2,'; chown test:test '/var/lib/mail/test/Maildir/cur/native:2,'") + machine.succeed(doveadm + " fetch -u test 'hdr.subject' mailbox INBOX ALL | grep -F 'Wayfinder fixture two'") + machine.succeed(doveadm + " flags add -u test '\\Flagged' mailbox INBOX ALL") + machine.succeed("find /var/lib/mail/test/Maildir/cur -name 'native:2,*F*' | grep .") + machine.succeed("rm /var/lib/mail/test/Maildir/cur/native:2,*") + + # Physical canonical encoding is percent escaping followed by Dovecot modified UTF-7. + machine.succeed(as_test + " test -d '/run/tag-view/caf&AOk-/cur'") + machine.succeed(as_test + " test -d '/run/tag-view/&U,BTFw-/cur'") + machine.succeed(as_test + " test -d /run/tag-view/100%25/cur") + machine.succeed(as_test + " test -d /run/tag-view/%2Ehidden/cur") + machine.succeed(as_test + " test -d /run/tag-view/%7Ehome/cur") + machine.succeed(as_test + " test -d /run/tag-view/%49NBOX/cur") + machine.succeed(as_test + " test -d /run/tag-view/%63ur/cur") + ${if separator == "." then + ''machine.succeed(as_test + " test -d /run/tag-view/team%2Eops/cur")'' + else + ''machine.succeed(as_test + " test -d /run/tag-view/team.ops/cur")''} + + # Real Dovecot decodes storage mUTF-7 and lists flat UTF-8 mailbox names. + machine.succeed(doveadm + " mailbox list -u test | grep -Fx '${visibleShared}'") + machine.succeed(doveadm + " mailbox list -u test | grep -Fx '${visible "café"}'") + machine.succeed(doveadm + " mailbox list -u test | grep -Fx '${visible "台北"}'") + + # Reads and new -> cur are driven by Dovecot, not direct FUSE operations. + machine.succeed(doveadm + " fetch -u test 'hdr.subject body' mailbox '${visibleAlpha}' ALL | grep -F 'Wayfinder fixture one'") + machine.succeed(doveadm + " flags add -u test '\\Seen' mailbox '${visibleAlpha}' ALL") + machine.succeed("find /run/source/cur -name '1700000000.M1P1.local*' | grep .") + machine.succeed("test -z \"$(find /run/source/new -name '1700000000.M1P1.local*' -print -quit)\"") + machine.succeed("test -z \"$(" + as_test + " find /run/tag-view/shared/new -type f -print -quit)\"") + machine.succeed(doveadm + " flags remove -u test '\\Seen' mailbox '${visibleAlpha}' ALL") + machine.succeed("find /run/source/cur -name '1700000000.M1P1.local*:2,*' | grep .") + + # Dovecot adds and removes initially absent standard flags; every projection sees them. + for imap_flag, maildir_flag in [(r"\Flagged", "F"), (r"\Answered", "R"), (r"\Draft", "D")]: + search_flag = imap_flag[1:].upper() + machine.succeed(doveadm + " flags add -u test '" + imap_flag + "' mailbox '${visibleAlpha}' ALL") + machine.succeed("find /run/source/cur -name '1700000000.M1P1.local*:2,*" + maildir_flag + "*' | grep .") + machine.succeed(doveadm + " search -u test mailbox '${visibleShared}' " + search_flag + " | grep .") + machine.succeed(doveadm + " flags remove -u test '" + imap_flag + "' mailbox '${visibleShared}' ALL") + machine.fail("find /run/source/cur -name '1700000000.M1P1.local*:2,*" + maildir_flag + "*' | grep .") + machine.fail(doveadm + " search -u test mailbox '${visibleAlpha}' " + search_flag + " | grep .") + + machine.succeed(doveadm + " flags add -u test '\\Seen' mailbox '${visibleAlpha}' ALL") + machine.succeed("find /run/source/cur -name '1700000000.M1P1.local*:2,*S*' | grep .") + machine.succeed(doveadm + " flags remove -u test '\\Seen' mailbox '${visibleShared}' ALL") + machine.fail("find /run/source/cur -name '1700000000.M1P1.local*:2,*S*' | grep .") + + # Record native Dovecot identity before restarts and mutations. + identity = doveadm + " mailbox status -u test 'uidvalidity uidnext messages' '${visibleAlpha}'; " + doveadm + " search -u test mailbox '${visibleAlpha}' ALL" + machine.succeed("(" + identity + ") > /tmp/identity.before") + machine.succeed("find /var/lib/dovecot/tag-control -type f -name dovecot-uidlist | grep .") + + # Dovecot master restart preserves UIDVALIDITY and UIDs. + machine.succeed(doveadm + " stop") + machine.wait_until_fails("test -e /run/dovecot/master.pid") + machine.succeed(dovecot_bin) + machine.succeed("(" + identity + ") > /tmp/identity.after-dovecot") + machine.succeed("cmp /tmp/identity.before /tmp/identity.after-dovecot") + + # FUSE daemon restart/remount also preserves Dovecot-owned identity metadata. + unmount_projection() + machine.fail(as_test + " touch /run/tag-view/escape") + mount_projection() + machine.succeed("(" + identity + ") > /tmp/identity.after-fuse") + machine.succeed("cmp /tmp/identity.before /tmp/identity.after-fuse") + + # A standard-flag rename after both restarts does not churn UID identity. + machine.succeed(doveadm + " flags add -u test '\\Flagged' mailbox '${visibleAlpha}' ALL") + machine.succeed("(" + identity + ") > /tmp/identity.after-flag") + machine.succeed("cmp /tmp/identity.before /tmp/identity.after-flag") + machine.succeed(doveadm + " flags remove -u test '\\Flagged' mailbox '${visibleAlpha}' ALL") + + # Dovecot COPY out succeeds by byte copy, while MOVE out copies then globally expunges. + machine.succeed(doveadm + " copy -u test INBOX mailbox '${visibleAlpha}' ALL") + machine.succeed(doveadm + " fetch -u test 'hdr.subject' mailbox INBOX ALL | grep -F 'Wayfinder fixture one'") + machine.succeed(doveadm + " move -u test INBOX mailbox '${visibleBeta}' ALL") + machine.succeed(doveadm + " fetch -u test 'hdr.subject' mailbox INBOX ALL | grep -F 'Wayfinder fixture two'") + machine.fail("find /run/source/{cur,new} -name '1700000001.M2P1.local*' | grep .") + machine.fail(as_test + " find /run/tag-view/shared/{cur,new} -name '1700000001.M2P1.local*' | grep .") + + # APPEND/COPY/MOVE into a tag folder and mailbox mutations are rejected via doveadm. + machine.succeed("find /run/source -type f -exec sha256sum {} + | sort > /tmp/source.before-reject") + machine.succeed(doveadm + " search -u test mailbox INBOX ALL > /tmp/inbox.before-reject") + machine.succeed(doveadm + " mailbox list -u test | sort > /tmp/mailboxes.before-reject") + machine.fail("printf 'Subject: rejected\\n\\nbody\\n' | " + doveadm + " save -u test -m '${visibleShared}'") + machine.fail(doveadm + " copy -u test '${visibleShared}' mailbox INBOX ALL") + machine.fail(doveadm + " move -u test '${visibleShared}' mailbox INBOX ALL") + machine.fail(doveadm + " mailbox create -u test '${visible "created"}'") + machine.fail(doveadm + " mailbox rename -u test '${visibleShared}' '${visible "renamed"}'") + machine.fail(as_test + " sh -c 'printf bad >> /run/tag-view/shared/cur/*'") + machine.succeed("find /run/source -type f -exec sha256sum {} + | sort > /tmp/source.after-reject; cmp /tmp/source.before-reject /tmp/source.after-reject") + machine.succeed(doveadm + " search -u test mailbox INBOX ALL > /tmp/inbox.after-reject; cmp /tmp/inbox.before-reject /tmp/inbox.after-reject") + machine.succeed(doveadm + " mailbox list -u test | sort > /tmp/mailboxes.after-reject; cmp /tmp/mailboxes.before-reject /tmp/mailboxes.after-reject") + + # Deleted is a Dovecot flag rename; EXPUNGE is a Dovecot-driven global unlink. + machine.succeed(doveadm + " flags add -u test '\\Deleted' mailbox '${visibleAlpha}' ALL") + machine.succeed("find /run/source/cur -name '1700000000.M1P1.local*:2,*T*' | grep .") + machine.succeed(doveadm + " flags remove -u test '\\Deleted' mailbox '${visibleShared}' ALL") + machine.fail("find /run/source/cur -name '1700000000.M1P1.local*:2,*T*' | grep .") + machine.succeed(doveadm + " flags add -u test '\\Deleted' mailbox '${visibleAlpha}' ALL") + machine.succeed(doveadm + " expunge -u test mailbox '${visibleAlpha}' DELETED") + machine.fail("find /run/source/{cur,new} -name '1700000000.M1P1.local*' | grep .") + machine.fail(as_test + " find /run/tag-view/shared/{cur,new} -name '1700000000.M1P1.local*' | grep .") + + # Polling reconciles external delivery, flag rename, and unlink without remounting. + machine.succeed("cp ${../fixtures/source}/cur/* /run/source/new/external.M3,S=132,W=138") + machine.succeed("chown test:test /run/source/new/external.M3,S=132,W=138") + machine.wait_until_succeeds(as_test + " find /run/tag-view/shared/new -name 'external.M3*' | grep .") + machine.succeed(doveadm + " fetch -u test 'hdr.subject' mailbox '${visibleShared}' ALL | grep -F 'Wayfinder fixture two'") + machine.succeed("source_name=$(find /run/source/{new,cur} -name 'external.M3*' -print -quit); test -n \"$source_name\"; mv \"$source_name\" /run/source/cur/external.M3,S=132,W=138:2,F") + machine.wait_until_succeeds(as_test + " find /run/tag-view/shared/cur -name 'external.M3*:2,F' | grep .") + machine.succeed(doveadm + " search -u test mailbox '${visibleShared}' FLAGGED | grep .") + machine.succeed("rm /run/source/cur/external.M3,S=132,W=138:2,F") + machine.wait_until_fails(as_test + " find /run/tag-view/shared/{cur,new} -name 'external.M3*' | grep .") + machine.fail(doveadm + " search -u test mailbox '${visibleShared}' ALL | grep .") + + # All Dovecot metadata remains on native external control/index storage. + machine.succeed("find /var/lib/dovecot/tag-control /var/lib/dovecot/tag-index -type f | grep .") + machine.fail(as_test + " find /run/tag-view \\( -name 'dovecot-*' -o -name subscriptions -o -name maildirfolder -o -name '*.lock' \\) -print | grep .") + + # Dovecot ACLs reject structural mutation before filesystem operations begin. + # FUSE cannot distinguish mailbox-delete unlinks from MOVE/EXPUNGE by itself. + machine.succeed("cp ${../fixtures/source}/cur/* '/run/source/cur/mailbox-delete.M4,S=132,W=138:2,a'; chown test:test '/run/source/cur/mailbox-delete.M4,S=132,W=138:2,a'") + machine.wait_until_succeeds(as_test + " find /run/tag-view/shared/cur -name 'mailbox-delete.M4*' | grep .") + machine.succeed(doveadm + " search -u test mailbox '${visibleShared}' ALL | grep .") + machine.fail(doveadm + " mailbox delete -u test '${visibleShared}'") + machine.succeed("find /run/source/{cur,new} -name 'mailbox-delete.M4*' | grep .") + + machine.succeed(doveadm + " stop") + unmount_projection() + ''; + }); +in +# Run under QEMU TCG when KVM is unavailable. +test.config.rawTestDerivation.overrideAttrs (_: { + requiredSystemFeatures = [ "nixos-test" ]; +}) diff --git a/prototype/wayfinder-6/src/main.rs b/prototype/wayfinder-6/src/main.rs new file mode 100644 index 0000000..1eb76b8 --- /dev/null +++ b/prototype/wayfinder-6/src/main.rs @@ -0,0 +1,872 @@ +//! PROTOTYPE / THROWAWAY: enough real FUSE and Maildir behavior to black-box Dovecot. +//! It deliberately uses polling, an in-memory index, and a fixture-only header parser. + +use fuser::{ + AccessFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, + INodeNo, LockOwner, MountOption, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, + ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, WriteFlags, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::io; +use std::os::unix::fs::{FileExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use std::time::{Duration, SystemTime}; + +const TTL: Duration = Duration::ZERO; +const ROOT_INO: u64 = 1; +const STANDARD_FLAGS: &str = "DFRST"; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum Node { + Root, + Tag(String), + Maildir { tag: String, place: Place }, + Message { tag: String, base: String }, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum Place { + Cur, + New, + Tmp, +} + +impl Place { + fn as_str(self) -> &'static str { + match self { + Self::Cur => "cur", + Self::New => "new", + Self::Tmp => "tmp", + } + } + + fn parse(value: &OsStr) -> Option { + match value.to_str()? { + "cur" => Some(Self::Cur), + "new" => Some(Self::New), + "tmp" => Some(Self::Tmp), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +struct Message { + base: String, + name: String, + place: Place, + path: PathBuf, + tags: BTreeSet, + metadata: fs::Metadata, +} + +#[derive(Clone)] +struct Snapshot { + messages: BTreeMap, + tags: BTreeSet, + scanned_at: SystemTime, +} + +impl Snapshot { + fn scan(source: &Path, separator: char) -> io::Result { + let mut messages = BTreeMap::new(); + let mut tags = BTreeSet::new(); + for place in [Place::New, Place::Cur] { + let directory = source.join(place.as_str()); + fs::create_dir_all(&directory)?; + for entry in fs::read_dir(directory)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let base = stable_base(&name).to_owned(); + let raw_tags = fixture_tags(&entry.path())?; + let encoded_tags: BTreeSet<_> = raw_tags + .into_iter() + .filter_map(|tag| encode_tag(&tag, separator)) + .collect(); + tags.extend(encoded_tags.iter().cloned()); + messages.insert( + base.clone(), + Message { + base, + name, + place, + path: entry.path(), + tags: encoded_tags, + metadata: entry.metadata()?, + }, + ); + } + } + Ok(Self { + messages, + tags, + scanned_at: SystemTime::now(), + }) + } + + fn node_for_ino(&self, ino: u64) -> Option { + if ino == ROOT_INO { + return Some(Node::Root); + } + for tag in &self.tags { + let node = Node::Tag(tag.clone()); + if inode(&node) == ino { + return Some(node); + } + for place in [Place::Cur, Place::New, Place::Tmp] { + let node = Node::Maildir { + tag: tag.clone(), + place, + }; + if inode(&node) == ino { + return Some(node); + } + } + } + for message in self.messages.values() { + for tag in &message.tags { + let node = Node::Message { + tag: tag.clone(), + base: message.base.clone(), + }; + if inode(&node) == ino { + return Some(node); + } + } + } + None + } + + fn lookup(&self, parent: u64, name: &OsStr) -> Option { + match self.node_for_ino(parent)? { + Node::Root => { + let tag = name.to_str()?; + self.tags.contains(tag).then(|| Node::Tag(tag.to_owned())) + } + Node::Tag(tag) => Some(Node::Maildir { + tag, + place: Place::parse(name)?, + }), + Node::Maildir { tag, place } => { + let name = name.to_str()?; + let base = stable_base(name); + let message = self.messages.get(base)?; + (message.tags.contains(&tag) && message.place == place && message.name == name) + .then(|| Node::Message { + tag, + base: base.to_owned(), + }) + } + Node::Message { .. } => None, + } + } +} + +struct PrototypeFs { + source: PathBuf, + separator: char, + state: Arc>, + mutation: Mutex<()>, + uid: u32, + gid: u32, +} + +impl PrototypeFs { + fn snapshot(&self) -> std::sync::RwLockReadGuard<'_, Snapshot> { + self.state.read().expect("snapshot lock poisoned") + } + + fn refresh(&self) -> Result<(), Errno> { + let next = Snapshot::scan(&self.source, self.separator).map_err(errno)?; + *self.state.write().expect("snapshot lock poisoned") = next; + Ok(()) + } + + fn attr(&self, node: &Node, snapshot: &Snapshot) -> Option { + let now = snapshot.scanned_at; + let (kind, perm, nlink, size, blocks, atime, mtime, ctime) = match node { + Node::Message { base, .. } => { + let message = snapshot.messages.get(base)?; + let meta = &message.metadata; + ( + FileType::RegularFile, + 0o644, + 1, + meta.len(), + meta.blocks(), + unix_time(meta.atime(), meta.atime_nsec()), + unix_time(meta.mtime(), meta.mtime_nsec()), + unix_time(meta.ctime(), meta.ctime_nsec()), + ) + } + Node::Maildir { place, .. } => { + let meta = fs::metadata(self.source.join(place.as_str())).ok()?; + ( + FileType::Directory, + 0o755, + 2, + 0, + meta.blocks(), + unix_time(meta.atime(), meta.atime_nsec()), + unix_time(meta.mtime(), meta.mtime_nsec()), + unix_time(meta.ctime(), meta.ctime_nsec()), + ) + } + _ => (FileType::Directory, 0o755, 2, 0, 0, now, now, now), + }; + Some(FileAttr { + ino: INodeNo(inode(node)), + size, + blocks, + atime, + mtime, + ctime, + crtime: ctime, + kind, + perm, + nlink, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 4096, + flags: 0, + }) + } + + fn parent_maildir(&self, ino: u64) -> Option<(String, Place)> { + match self.snapshot().node_for_ino(ino)? { + Node::Maildir { tag, place } => Some((tag, place)), + _ => None, + } + } +} + +impl Filesystem for PrototypeFs { + fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + let snapshot = self.snapshot(); + match snapshot.lookup(parent.0, name) { + Some(node) => reply.entry(&TTL, &self.attr(&node, &snapshot).unwrap(), Generation(0)), + None => reply.error(Errno::ENOENT), + } + } + + fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let snapshot = self.snapshot(); + match snapshot + .node_for_ino(ino.0) + .and_then(|node| self.attr(&node, &snapshot)) + { + Some(attr) => reply.attr(&TTL, &attr), + None => reply.error(Errno::ENOENT), + } + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let snapshot = self.snapshot(); + if !matches!(snapshot.node_for_ino(ino.0), Some(Node::Message { .. })) { + reply.error(Errno::ENOENT); + } else if flags.acc_mode() != OpenAccMode::O_RDONLY { + reply.error(Errno::EROFS); + } else { + reply.opened(FileHandle(0), FopenFlags::empty()); + } + } + + fn read( + &self, + _req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyData, + ) { + let path = { + let snapshot = self.snapshot(); + match snapshot.node_for_ino(ino.0) { + Some(Node::Message { base, .. }) => { + snapshot.messages.get(&base).map(|m| m.path.clone()) + } + _ => None, + } + }; + let Some(path) = path else { + reply.error(Errno::ENOENT); + return; + }; + let result = (|| { + let file = fs::File::open(path)?; + let mut bytes = vec![0; size as usize]; + let count = file.read_at(&mut bytes, offset)?; + bytes.truncate(count); + Ok::<_, io::Error>(bytes) + })(); + match result { + Ok(bytes) => reply.data(&bytes), + Err(error) => reply.error(errno(error)), + } + } + + fn readdir( + &self, + _req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + mut reply: ReplyDirectory, + ) { + let snapshot = self.snapshot(); + let Some(node) = snapshot.node_for_ino(ino.0) else { + reply.error(Errno::ENOENT); + return; + }; + let parent = match &node { + Node::Maildir { tag, .. } => inode(&Node::Tag(tag.clone())), + _ => ROOT_INO, + }; + let mut entries: Vec<(u64, FileType, OsString)> = vec![ + (ino.0, FileType::Directory, ".".into()), + (parent, FileType::Directory, "..".into()), + ]; + match node { + Node::Root => entries.extend(snapshot.tags.iter().map(|tag| { + let node = Node::Tag(tag.clone()); + (inode(&node), FileType::Directory, tag.into()) + })), + Node::Tag(tag) => entries.extend([Place::Cur, Place::New, Place::Tmp].map(|place| { + let node = Node::Maildir { + tag: tag.clone(), + place, + }; + (inode(&node), FileType::Directory, place.as_str().into()) + })), + Node::Maildir { tag, place } => entries.extend( + snapshot + .messages + .values() + .filter(|message| message.place == place && message.tags.contains(&tag)) + .map(|message| { + let node = Node::Message { + tag: tag.clone(), + base: message.base.clone(), + }; + (inode(&node), FileType::RegularFile, (&message.name).into()) + }), + ), + Node::Message { .. } => { + reply.error(Errno::EINVAL); + return; + } + } + for (index, (child, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) { + if reply.add(INodeNo(child), (index + 1) as u64, kind, name) { + break; + } + } + reply.ok(); + } + + fn rename( + &self, + _req: &Request, + parent: INodeNo, + name: &OsStr, + newparent: INodeNo, + newname: &OsStr, + flags: RenameFlags, + reply: ReplyEmpty, + ) { + if !flags.is_empty() { + reply.error(Errno::EOPNOTSUPP); + return; + } + let Some((tag, old_place)) = self.parent_maildir(parent.0) else { + reply.error(Errno::EROFS); + return; + }; + let Some((new_tag, new_place)) = self.parent_maildir(newparent.0) else { + reply.error(Errno::EROFS); + return; + }; + let (Some(old_name), Some(new_name)) = (name.to_str(), newname.to_str()) else { + reply.error(Errno::EINVAL); + return; + }; + if tag != new_tag || stable_base(old_name) != stable_base(new_name) { + reply.error(Errno::EOPNOTSUPP); + return; + } + if !matches!( + (old_place, new_place), + (Place::Cur, Place::Cur) | (Place::New, Place::New) | (Place::New, Place::Cur) + ) { + reply.error(Errno::EOPNOTSUPP); + return; + } + + let _guard = self.mutation.lock().expect("mutation lock poisoned"); + if let Err(error) = self.refresh() { + reply.error(error); + return; + } + let base = stable_base(old_name).to_owned(); + let current = self.snapshot().messages.get(&base).cloned(); + let Some(current) = current.filter(|message| message.tags.contains(&tag)) else { + reply.error(Errno::ENOENT); + return; + }; + + let old_flags = maildir_flags(old_name); + let requested_flags = maildir_flags(new_name); + if opaque_flags(&old_flags) != opaque_flags(&requested_flags) { + reply.error(Errno::EOPNOTSUPP); + return; + } + let current_flags = maildir_flags(¤t.name); + let merged = merge_standard_delta(¤t_flags, &old_flags, &requested_flags); + let destination_name = format!("{}:2,{}", current.base, merged); + let destination_place = if old_place == Place::New { + Place::Cur + } else { + new_place + }; + let destination = self + .source + .join(destination_place.as_str()) + .join(destination_name); + match fs::rename(¤t.path, destination) + .map_err(errno) + .and_then(|_| self.refresh()) + { + Ok(()) => reply.ok(), + Err(error) => reply.error(error), + } + } + + fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let Some((tag, place)) = self.parent_maildir(parent.0) else { + reply.error(Errno::EROFS); + return; + }; + if place == Place::Tmp { + reply.error(Errno::EROFS); + return; + } + let Some(name) = name.to_str() else { + reply.error(Errno::EINVAL); + return; + }; + let _guard = self.mutation.lock().expect("mutation lock poisoned"); + if let Err(error) = self.refresh() { + reply.error(error); + return; + } + let current = self.snapshot().messages.get(stable_base(name)).cloned(); + let Some(current) = current.filter(|message| message.tags.contains(&tag)) else { + // Expunge is idempotent for a stale projection. + reply.ok(); + return; + }; + match fs::remove_file(¤t.path) + .map_err(errno) + .and_then(|_| self.refresh()) + { + Ok(()) => reply.ok(), + Err(error) if i32::from(error) == libc::ENOENT => { + let _ = self.refresh(); + reply.ok(); + } + Err(error) => reply.error(error), + } + } + + fn access(&self, _req: &Request, ino: INodeNo, _mask: AccessFlags, reply: ReplyEmpty) { + if self.snapshot().node_for_ino(ino.0).is_none() { + reply.error(Errno::ENOENT); + } else { + reply.ok(); + } + } + + fn write( + &self, + _req: &Request, + _ino: INodeNo, + _fh: FileHandle, + _offset: u64, + _data: &[u8], + _write_flags: WriteFlags, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyWrite, + ) { + reply.error(Errno::EROFS); + } + + fn create( + &self, + _req: &Request, + _parent: INodeNo, + _name: &OsStr, + _mode: u32, + _umask: u32, + _flags: i32, + reply: ReplyCreate, + ) { + reply.error(Errno::EOPNOTSUPP); + } + + fn mkdir( + &self, + _req: &Request, + _parent: INodeNo, + _name: &OsStr, + _mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + reply.error(Errno::EOPNOTSUPP); + } + + fn rmdir(&self, _req: &Request, _parent: INodeNo, _name: &OsStr, reply: ReplyEmpty) { + reply.error(Errno::EOPNOTSUPP); + } +} + +fn stable_base(name: &str) -> &str { + name.split_once(":2,").map_or(name, |(base, _)| base) +} + +fn maildir_flags(name: &str) -> BTreeSet { + name.split_once(":2,") + .map(|(_, flags)| flags.chars().collect()) + .unwrap_or_default() +} + +fn opaque_flags(flags: &BTreeSet) -> BTreeSet { + flags + .iter() + .copied() + .filter(|flag| !STANDARD_FLAGS.contains(*flag)) + .collect() +} + +fn merge_standard_delta( + current: &BTreeSet, + old: &BTreeSet, + requested: &BTreeSet, +) -> String { + let mut merged = current.clone(); + for flag in STANDARD_FLAGS.chars() { + if requested.contains(&flag) && !old.contains(&flag) { + merged.insert(flag); + } else if old.contains(&flag) && !requested.contains(&flag) { + merged.remove(&flag); + } + } + merged.into_iter().collect() +} + +fn fixture_tags(path: &Path) -> io::Result> { + let bytes = fs::read(path)?; + let header_end = bytes + .windows(2) + .position(|window| window == b"\n\n") + .unwrap_or(bytes.len()); + let headers = String::from_utf8_lossy(&bytes[..header_end.min(64 * 1024)]); + let mut tags = BTreeSet::new(); + for line in headers.lines() { + if let Some(values) = line.strip_prefix("X-Wayfinder-Tags:") { + tags.extend( + values + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_owned), + ); + } + let lower = line.to_ascii_lowercase(); + if lower.starts_with("to:") + || lower.starts_with("delivered-to:") + || lower.starts_with("x-original-to:") + { + for token in line + .split(|character: char| character.is_whitespace() || "<>,;\"".contains(character)) + { + if let Some((_, suffix)) = token.split_once('+') { + if let Some((tag, _)) = suffix.rsplit_once('@') { + if !tag.is_empty() { + tags.insert(tag.to_owned()); + } + } + } + } + } + } + Ok(tags) +} + +fn percent_escape_tag(tag: &str, separator: char) -> Option { + if tag.is_empty() { + return None; + } + let reserved = tag.starts_with('.') + || tag.starts_with('~') + || tag.eq_ignore_ascii_case("inbox") + || matches!(tag, "cur" | "new" | "tmp"); + let mut escaped = String::new(); + for (index, character) in tag.char_indices() { + let escape = character == '%' + || character == '/' + || character == separator + || character.is_control() + || (index == 0 && reserved); + if escape { + let mut bytes = [0; 4]; + for byte in character.encode_utf8(&mut bytes).as_bytes() { + escaped.push_str(&format!("%{byte:02X}")); + } + } else { + escaped.push(character); + } + } + Some(escaped) +} + +fn modified_utf7_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"; + let mut encoded = String::new(); + let mut accumulator = 0_u32; + let mut bits = 0_u8; + for byte in bytes { + accumulator = (accumulator << 8) | u32::from(*byte); + bits += 8; + while bits >= 6 { + bits -= 6; + encoded.push(ALPHABET[((accumulator >> bits) & 0x3f) as usize] as char); + } + } + if bits != 0 { + encoded.push(ALPHABET[((accumulator << (6 - bits)) & 0x3f) as usize] as char); + } + encoded +} + +fn dovecot_modified_utf7(value: &str) -> String { + let mut encoded = String::new(); + let mut non_ascii = Vec::new(); + let flush_non_ascii = |encoded: &mut String, non_ascii: &mut Vec| { + if !non_ascii.is_empty() { + encoded.push('&'); + encoded.push_str(&modified_utf7_base64(non_ascii)); + encoded.push('-'); + non_ascii.clear(); + } + }; + + for character in value.chars() { + if (' '..='~').contains(&character) { + flush_non_ascii(&mut encoded, &mut non_ascii); + if character == '&' { + encoded.push_str("&-"); + } else { + encoded.push(character); + } + } else { + let mut units = [0; 2]; + for unit in character.encode_utf16(&mut units) { + non_ascii.extend_from_slice(&unit.to_be_bytes()); + } + } + } + flush_non_ascii(&mut encoded, &mut non_ascii); + encoded +} + +fn encode_tag(tag: &str, separator: char) -> Option { + let escaped = percent_escape_tag(tag, separator)?; + let encoded = dovecot_modified_utf7(&escaped); + (encoded.len() <= 255).then_some(encoded) +} + +fn inode(node: &Node) -> u64 { + if matches!(node, Node::Root) { + return ROOT_INO; + } + // Stable, projection-specific FNV-1a for the disposable prototype. + struct Fnv(u64); + impl Hasher for Fnv { + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= *byte as u64; + self.0 = self.0.wrapping_mul(0x100000001b3); + } + } + fn finish(&self) -> u64 { + self.0 + } + } + let mut hasher = Fnv(0xcbf29ce484222325); + node.hash(&mut hasher); + (hasher.finish() & 0x7fff_ffff_ffff_ffff).max(2) +} + +fn unix_time(seconds: i64, nanos: i64) -> SystemTime { + if seconds < 0 || nanos < 0 { + SystemTime::UNIX_EPOCH + } else { + SystemTime::UNIX_EPOCH + Duration::new(seconds as u64, nanos as u32) + } +} + +fn errno(error: io::Error) -> Errno { + error.into() +} + +fn usage() -> ! { + eprintln!( + "PROTOTYPE — wipe me\nusage: wayfinder-6-prototype <. | />" + ); + std::process::exit(2) +} + +fn main() { + let mut args = std::env::args_os().skip(1); + let source = args.next().map(PathBuf::from).unwrap_or_else(|| usage()); + let mountpoint = args.next().map(PathBuf::from).unwrap_or_else(|| usage()); + let separator = args + .next() + .and_then(|value| value.to_str().and_then(|value| value.chars().next())) + .filter(|value| matches!(value, '.' | '/')) + .unwrap_or_else(|| usage()); + if args.next().is_some() { + usage(); + } + let initial = Snapshot::scan(&source, separator).unwrap_or_else(|error| { + eprintln!("initial source reconciliation failed: {error}"); + std::process::exit(1); + }); + let state = Arc::new(RwLock::new(initial)); + let polling_state = Arc::clone(&state); + let polling_source = source.clone(); + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(100)); + if let Ok(snapshot) = Snapshot::scan(&polling_source, separator) { + *polling_state.write().expect("snapshot lock poisoned") = snapshot; + } + }); + let filesystem = PrototypeFs { + source, + separator, + state, + mutation: Mutex::new(()), + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + }; + let options = vec![ + MountOption::FSName("wayfinder-6-prototype".into()), + MountOption::DefaultPermissions, + ]; + let mut config = Config::default(); + config.mount_options = options; + eprintln!("PROTOTYPE — wipe me; polling source every 100 ms"); + fuser::mount2(filesystem, mountpoint, &config).unwrap_or_else(|error| { + eprintln!("mount failed: {error}"); + std::process::exit(1); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_standard_flag_delta_preserves_concurrent_and_opaque_flags() { + let current = maildir_flags("id:2,FSa"); + let stale_old = maildir_flags("id:2,Sa"); + let requested = maildir_flags("id:2,RSa"); + assert_eq!( + merge_standard_delta(¤t, &stale_old, &requested), + "FRSa" + ); + } + + #[test] + fn inode_differs_between_projections() { + let alpha = Node::Message { + tag: "alpha".into(), + base: "id".into(), + }; + let beta = Node::Message { + tag: "beta".into(), + base: "id".into(), + }; + assert_ne!(inode(&alpha), inode(&beta)); + } + + #[test] + fn percent_encoding_preserves_utf8_and_escapes_canonical_edges() { + assert_eq!(percent_escape_tag("café", '/').as_deref(), Some("café")); + assert_eq!( + percent_escape_tag("team.ops", '.').as_deref(), + Some("team%2Eops") + ); + assert_eq!(percent_escape_tag("100%", '/').as_deref(), Some("100%25")); + assert_eq!(percent_escape_tag("a/b", '/').as_deref(), Some("a%2Fb")); + assert_eq!( + percent_escape_tag(".hidden", '/').as_deref(), + Some("%2Ehidden") + ); + assert_eq!(percent_escape_tag("~home", '/').as_deref(), Some("%7Ehome")); + assert_eq!(percent_escape_tag("INBOX", '/').as_deref(), Some("%49NBOX")); + assert_eq!(percent_escape_tag("cur", '/').as_deref(), Some("%63ur")); + assert_eq!(percent_escape_tag("a\0b", '/').as_deref(), Some("a%00b")); + } + + #[test] + fn dovecot_modified_utf7_matches_known_vectors() { + assert_eq!(dovecot_modified_utf7("café"), "caf&AOk-"); + assert_eq!(dovecot_modified_utf7("台北"), "&U,BTFw-"); + assert_eq!(dovecot_modified_utf7("A&B"), "A&-B"); + assert_eq!(dovecot_modified_utf7("日本語"), "&ZeVnLIqe-"); + } + + #[test] + fn canonical_tag_encoding_percent_escapes_before_modified_utf7() { + assert_eq!(encode_tag("team.ops", '.').as_deref(), Some("team%2Eops")); + assert_eq!(encode_tag("team.ops", '/').as_deref(), Some("team.ops")); + assert_eq!(encode_tag("café", '/').as_deref(), Some("caf&AOk-")); + assert_eq!(encode_tag("台北", '/').as_deref(), Some("&U,BTFw-")); + assert_eq!(encode_tag("cur", '/').as_deref(), Some("%63ur")); + assert_eq!(encode_tag("", '/'), None); + assert_eq!(encode_tag(&"é".repeat(100), '/'), None); + } + + #[test] + fn snapshot_extracts_fixture_tags() { + let temporary = tempfile::tempdir().unwrap(); + for place in ["cur", "new", "tmp"] { + fs::create_dir(temporary.path().join(place)).unwrap(); + } + fs::write( + temporary.path().join("new/id"), + "To: Person \nX-Wayfinder-Tags: beta\n\nbody\n", + ) + .unwrap(); + let snapshot = Snapshot::scan(temporary.path(), '/').unwrap(); + assert_eq!( + snapshot.tags, + BTreeSet::from(["alpha".into(), "beta".into()]) + ); + } +}