prototype: validate writable Dovecot tag projection
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5b767c536d
commit
a430dc1581
9 changed files with 1895 additions and 0 deletions
44
flake.lock
generated
Normal file
44
flake.lock
generated
Normal 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
79
flake.nix
Normal 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
509
prototype/wayfinder-6/Cargo.lock
generated
Normal 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",
|
||||
]
|
||||
12
prototype/wayfinder-6/Cargo.toml
Normal file
12
prototype/wayfinder-6/Cargo.toml
Normal 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"
|
||||
52
prototype/wayfinder-6/README.md
Normal file
52
prototype/wayfinder-6/README.md
Normal 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.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
Delivered-To: demo+beta@example.test
|
||||
X-Wayfinder-Tags: shared
|
||||
Message-ID: <two@example.test>
|
||||
Subject: Wayfinder fixture two
|
||||
|
||||
second
|
||||
|
|
@ -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
|
||||
315
prototype/wayfinder-6/nix/vm-test.nix
Normal file
315
prototype/wayfinder-6/nix/vm-test.nix
Normal 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" ];
|
||||
})
|
||||
872
prototype/wayfinder-6/src/main.rs
Normal file
872
prototype/wayfinder-6/src/main.rs
Normal 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(¤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<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(¤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 <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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue