prototype: validate writable Dovecot tag projection

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
phg 2026-07-24 00:46:07 +02:00
parent 5b767c536d
commit a430dc1581
9 changed files with 1895 additions and 0 deletions

44
flake.lock generated Normal file
View file

@ -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
}

79
flake.nix Normal file
View file

@ -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";
};
};
};
}

509
prototype/wayfinder-6/Cargo.lock generated Normal file
View file

@ -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",
]

View file

@ -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"

View file

@ -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.

View file

@ -0,0 +1,6 @@
Delivered-To: demo+beta@example.test
X-Wayfinder-Tags: shared
Message-ID: <two@example.test>
Subject: Wayfinder fixture two
second

View file

@ -0,0 +1,6 @@
To: Demo <demo+alpha@example.test>
X-Wayfinder-Tags: shared, team.ops, café, 台北, 100%, .hidden, ~home, INBOX, cur
Message-ID: <one@example.test>
Subject: Wayfinder fixture one
one

View file

@ -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" ];
})

View file

@ -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<Self> {
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<String>,
metadata: fs::Metadata,
}
#[derive(Clone)]
struct Snapshot {
messages: BTreeMap<String, Message>,
tags: BTreeSet<String>,
scanned_at: SystemTime,
}
impl Snapshot {
fn scan(source: &Path, separator: char) -> io::Result<Self> {
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<Node> {
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<Node> {
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<RwLock<Snapshot>>,
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<FileAttr> {
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<FileHandle>, 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<LockOwner>,
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(&current.name);
let merged = merge_standard_delta(&current_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(&current.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(&current.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<LockOwner>,
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<char> {
name.split_once(":2,")
.map(|(_, flags)| flags.chars().collect())
.unwrap_or_default()
}
fn opaque_flags(flags: &BTreeSet<char>) -> BTreeSet<char> {
flags
.iter()
.copied()
.filter(|flag| !STANDARD_FLAGS.contains(*flag))
.collect()
}
fn merge_standard_delta(
current: &BTreeSet<char>,
old: &BTreeSet<char>,
requested: &BTreeSet<char>,
) -> 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<BTreeSet<String>> {
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<String> {
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<u8>| {
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<String> {
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 <source-maildir> <mountpoint> <. | />"
);
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(&current, &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 <demo+alpha@example.test>\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()])
);
}
}