diff --git a/.github/workflows/redirect-links.yml b/.github/workflows/redirect-links.yml new file mode 100644 index 00000000..1f2e2385 --- /dev/null +++ b/.github/workflows/redirect-links.yml @@ -0,0 +1,31 @@ +name: Redirect Links checks +on: + pull_request: + paths: + - 'applications/redirect-links/**' + - '.github/workflows/redirect-links.yml' + push: + branches: [main] + paths: + - 'applications/redirect-links/**' + - '.github/workflows/redirect-links.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: applications/redirect-links + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: sudo apt-get update && sudo apt-get install -y libpq-dev pkg-config + - name: Locked native checks + run: | + rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy + cargo fmt --check + cargo test --locked + cargo clippy --locked --all-targets -- -D warnings + cargo build --release --locked + python3 test/preflight.py "$PWD/target/release/redirect-links" diff --git a/applications/redirect-links/.env.example b/applications/redirect-links/.env.example new file mode 100644 index 00000000..682a5d03 --- /dev/null +++ b/applications/redirect-links/.env.example @@ -0,0 +1,12 @@ +PGHOST=your-cloud-host +PGPORT=5432 +PGDATABASE=postgres +PGUSER=redirects_app +PGPASSWORD=replace-with-runtime-password +PGSSLROOTCERT=/private/path/ca.pem +ADMIN_USER=cloud-created-user +ADMIN_PASSWORD=cloud-created-password +MIGRATION_PASSWORD=replace-with-separate-password +NORTH_TOKEN=replace-with-32-or-more-random-characters +SOUTH_TOKEN=replace-with-another-random-token +PORT=4000 diff --git a/applications/redirect-links/.gitignore b/applications/redirect-links/.gitignore new file mode 100644 index 00000000..16e8e782 --- /dev/null +++ b/applications/redirect-links/.gitignore @@ -0,0 +1,4 @@ +/target/ +.env +*.pem +__pycache__/ diff --git a/applications/redirect-links/Cargo.lock b/applications/redirect-links/Cargo.lock new file mode 100644 index 00000000..e4aca4ac --- /dev/null +++ b/applications/redirect-links/Cargo.lock @@ -0,0 +1,1803 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.18.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab7d72c0bb26f10df574ea560177b4de2f27ee8f421b2c1e9b75628bd9db23" +dependencies = [ + "actix-codec", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367f814ad4afbac74f07df5001214da65f65e185c90ef56c4dd8df23f8695b9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8db2cf6f034e227c0c79825fbf11f3cad48569109515f62542b84d8abedb9376" +dependencies = [ + "actix-rt", + "actix-service", + "futures-core", + "futures-util", + "mio", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0128396dd7313f697ad05b21b1a7be7d4cbb81888704f55996e4a27db196bb4d" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96b09c4878563f8ab4a5fd0c59f9f0d6e0e9f60eb9b748526a0b9604fd89c50" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "diesel" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "diesel_derives", + "downcast-rs", + "itoa", + "pq-sys", + "r2d2", +] + +[[package]] +name = "diesel_derives" +version = "2.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[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", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c0cddce6b7505483307994f60d97c4b156020e1504d0cf1f05a21b1b325e64" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "pq-sys" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9152654059e4fb5b66a4052acf3ee43a7a7132332e44cdb72a81ee93bd038950" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redirect-links" +version = "0.1.0" +dependencies = [ + "actix-web", + "chrono", + "diesel", + "r2d2", + "serde", + "serde_json", + "subtle", + "tokio", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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 = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/applications/redirect-links/Cargo.toml b/applications/redirect-links/Cargo.toml new file mode 100644 index 00000000..9285ff28 --- /dev/null +++ b/applications/redirect-links/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "redirect-links" +version = "0.1.0" +edition = "2024" +rust-version = "1.99" + +[dependencies] +actix-web = "=4.15.0" +diesel = { version = "=2.3.13", default-features = false, features = ["postgres", "r2d2", "chrono", "32-column-tables"] } +r2d2 = "=0.8.10" +tokio = { version = "=1.53.1", features = ["sync", "time"] } +chrono = { version = "=0.4.45", features = ["serde"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +url = "=2.5.8" +subtle = "=2.6.1" diff --git a/applications/redirect-links/README.md b/applications/redirect-links/README.md new file mode 100644 index 00000000..98107af5 --- /dev/null +++ b/applications/redirect-links/README.md @@ -0,0 +1,162 @@ +# Redirect Links + +A small Rust/Actix Web management API creates and disables operator-scoped redirect links on [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres/overview). Public GET/HEAD lookups return a temporary redirect only while a link is enabled and unexpired. The service never fetches, previews or checks the reputation of submitted destinations. + +This example deliberately integrates Diesel's synchronous PostgreSQL connection and typed query builder with Actix's blocking thread pool. It has no click analytics, external messaging, billing or browser visits to third-party sites. + +## Data and transaction model + +Two seeded operator accounts, north and south, have distinct configured bearer tokens. The server derives account identity from the token; owner fields are rejected. Shared tokens are trusted operator credentials, not end-user accounts. The shared runtime database role can read both accounts' data directly; API filters enforce operator boundaries, without per-user RLS. + +A link has a globally unique canonical slug, immutable destination/expiry, a revision and an optional disabled timestamp. Creation locks its account row, checks the 20-link cap, increments its counter and inserts the link on the same connection in one Diesel transaction. A slug collision rolls back the counter and returns 409. Creation has **no retained request key**: repeating a successful POST returns a slug conflict, even with the same destination. After a lost response, inspect the owner's list before deciding what to do next. + +Disable locks the owned link and compares the revision the operator saw. Enabled links move from revision 1 to 2. A repeated disable carrying 1 or the current disabled revision 2 returns the existing state; other positive revisions conflict. There is no re-enable or destination edit operation. Disabled and expired rows still consume the account's cap, keeping the teaching fixture finite. + +The runtime can SELECT accounts/links, INSERT links, use the identity sequence, UPDATE only account counters and UPDATE only disabled_at/revision. It cannot mutate owners/slugs/destinations/expiry, delete rows, read migration history, or create application/schema/temp objects. Constraints enforce global slug uniqueness/canonical grammar, account FK, positive identity, bounded counters, destination size/basic scheme and coherent revision/disabled state. Direct trusted runtime SQL can insert without updating the counter or change permitted metadata; the complete cap/counter/one-way-disable protocol depends on cooperating application code. + +## Blocking-work lifetime + +Pool checkout and every SQL operation run inside `web::block`, including the full multi-statement transaction. Four owned admission permits are shared across both HTTP workers. A permit moves into the blocking closure before checkout and remains there until actual work finishes. Dropping an awaiting HTTP future doesn't release capacity while its synchronous SQL continues; an abandoned operation can still commit. + +The r2d2 pool has four connections, zero minimum idle connections, a two-second checkout timeout, one-minute idle timeout and five-minute maximum lifetime. New libpq connections use `connect_timeout=5`, `sslmode=verify-full` and an explicit CA/hostname. Statement/lock/idle-transaction timeouts are four/two/six seconds. Connection establishment runs in r2d2's bounded pool management; checkout can time out while a background connection attempt completes. These are separate controls, not a total HTTP deadline. + +Actix has two HTTP workers, at most four blocking threads per worker, 32 connections **per worker**, a five-second initial-request timeout, five-second keepalive and ten-second graceful shutdown timeout. JSON bodies cap at 4 KiB. Lists return at most 20 rows with destinations bounded to 2 KiB each. Multiple server processes each have their own local limits; the database account lock coordinates their creation transactions. + +## Native setup and locked build + +Tested on 2 October 2026: Ubuntu 24.04 ARM64, Rust/Cargo 1.99.0, Actix Web 4.15.0, Diesel 2.3.13, r2d2 0.8.10, Tokio 1.53.1, URL 2.5.8 and libpq 16.15. Cargo.lock records 190 package entries; rust-toolchain.toml pins the compiler and components. All builds/dependencies stay in the native Linux filesystem. + +```sh +sudo apt-get update +sudo apt-get install -y build-essential libpq-dev postgresql-client pkg-config \ + libssl-dev curl ca-certificates git +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh +sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.99.0 +source "$HOME/.cargo/env" +cd /path/to/examples/applications/redirect-links +cargo fetch --locked +cargo fmt --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +cargo build --release --locked +python3 test/preflight.py "$PWD/target/release/redirect-links" +``` + +Three local tests cover canonical inputs/numeric/calendar bounds and a real blocked-closure cancellation/admission control. The separate compiled HTTP preflight runs only `/health` without a database and waits for SIGTERM exit. The Cloud cancellation test is explicitly ignored by default. CI runs these local checks without Cloud credentials. + +## Create a dedicated Cloud fixture + +Use an authenticated [clickhousectl](https://github.com/ClickHouse/clickhousectl) CLI. Creating a service starts billing; delete it after testing. Check current supported region/shape for your organization. The tested modest shape was: + +```sh +umask 077 +export ORG_ID=your-clickhouse-organization-id +clickhousectl cloud postgres create --org-id "$ORG_ID" \ + --name redirect-links-demo --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json > /private/path/create.json +export PG_ID=your-created-service-id +clickhousectl cloud postgres get "$PG_ID" --org-id "$ORG_ID" +# Repeat get until state is running, then fetch the official PEM: +clickhousectl cloud postgres certs get "$PG_ID" --org-id "$ORG_ID" \ + --output /private/path/ca.pem +``` + +Store the returned service ID, hostname, username and one-time password privately. `--output` writes PEM even when coding-agent CLI stdout is JSON. The actual Diesel factory uses libpq certificate **and** hostname verification with the full official bundle. Refresh that official bundle after certificate rotation; don't weaken verification to `require`. + +## Explicit migration and seed + +Copy `.env.example` to a private mode-600 setup.env outside the checkout. Fill in Cloud connection fields, administrator credentials, a separate migration password, the runtime password in PGPASSWORD, and two distinct 32–128 character printable ASCII tokens without spaces. Quote shell-sensitive values. Export fields before calling child processes: + +```sh +set -a; source /private/path/setup.env; set +a +export PGSSLMODE=verify-full +APP_PASSWORD=$PGPASSWORD +export PGUSER=$ADMIN_USER PGPASSWORD=$ADMIN_PASSWORD +psql -X -v MIGRATION_PASSWORD="$MIGRATION_PASSWORD" -v APP_PASSWORD="$APP_PASSWORD" \ + -f sql/bootstrap.sql +export PGUSER=redirects_migration PGPASSWORD=$MIGRATION_PASSWORD +./target/release/redirect-links migrate +psql -X -f sql/grants.sql +psql -X -f sql/seed.sql +``` + +Bootstrap creates the schema/roles and revokes PUBLIC database CREATE/TEMP and public-schema CREATE on this dedicated fixture. The schema owner needs no database CREATE. `migrate` uses the same reviewed SQL embedded in the binary, version history and an advisory transaction lock; repeat it safely. Seed creates north/south accounts without resetting existing counters. Normal server startup never migrates or synchronizes schema. The runtime role cannot run the migration command. + +## Run and use the API + +```sh +export PGUSER=redirects_app PGPASSWORD=$APP_PASSWORD +unset ADMIN_USER ADMIN_PASSWORD MIGRATION_PASSWORD +./target/release/redirect-links +``` + +The listener binds `127.0.0.1:4000`; PORT can change that local port. Startup verifies a runtime connection before opening the listener. `/health` reports process readiness, without a continuous database probe. SIGTERM requests graceful shutdown; the restart acceptance waits for the original process to exit before replacing it. + +Create a private mode-600 runtime.env with only PGHOST, PGPORT, PGDATABASE, PGUSER=redirects_app, its PGPASSWORD, PGSSLROOTCERT, NORTH_TOKEN, SOUTH_TOKEN and optional PORT. In a second shell: + +```sh +set -a; source /private/path/runtime.env; set +a +curl -sS http://127.0.0.1:4000/links \ + -H "Authorization: Bearer $NORTH_TOKEN" -H 'Content-Type: application/json' \ + --data '{"slug":"Docs-Guide","destination":"https://clickhouse.com/docs/products/managed-postgres/overview"}' +curl -sS 'http://127.0.0.1:4000/links?limit=10' -H "Authorization: Bearer $NORTH_TOKEN" +# Inspect redirect headers without following the destination: +curl -sS -I http://127.0.0.1:4000/r/docs-guide +curl -sS http://127.0.0.1:4000/links/docs-guide/disable \ + -H "Authorization: Bearer $NORTH_TOKEN" -H 'Content-Type: application/json' \ + --data '{"revision":"1"}' +``` + +| Route | Behavior | +| --- | --- | +| POST `/links` | 201 on creation, 409 on canonical slug collision/account full | +| GET `/links?limit=10&before=123` | Owner-scoped numeric identity descending; limit 1–20 | +| POST `/links/{slug}/disable` | 200 on revision-matched disable/repeat; 404 foreign/missing, 409 conflicting revision | +| GET/HEAD `/r/{slug}` | 307 plus canonical Location when active; 404 missing/invalid/disabled/expired; no-store | + +Slugs are 3–40 ASCII letters/digits with internal hyphens, normalized to lowercase. health/links/admin/api/static are reserved. Destinations use the URL parser, require explicit HTTP(S) authority/host, reject userinfo/control characters/surrounding whitespace and cap the canonical serialized URL at 2048 bytes. Expiry is null/omitted or RFC3339, normalized to UTC, years 1970–2100 with at most microsecond precision and no leap second. Past expiry is allowed and resolves to 404. Database `clock_timestamp()` determines expiry at lookup; an already authorized response cannot be retracted by a later disable. + +IDs/revisions are decimal strings. An initial page has no cursor predicate, so even INT64_MAX remains visible. Subsequent `before` is a positive signed bigint string of at most 19 digits. `next_before` contains the last row's ID; traverse until an empty page. Numeric ordering stays in Diesel/SQL while string serialization happens in Rust. Identity allocation order is not commit chronology; gaps are normal, and pages are live reads rather than a snapshot. + +## Dedicated Cloud acceptance + +Tests are destructive fixtures, not production checks. Stop the server, restore private setup credentials and run cleanup, then repeat bootstrap/migrate twice/grants/seed twice above. The compiled HTTP child receives only runtime fields; independent owner/admin controls stay in the test process. + +```sh +set -a; source /private/path/setup.env; set +a +export PGSSLMODE=verify-full +APP_PASSWORD=$PGPASSWORD +export PGUSER=$ADMIN_USER PGPASSWORD=$ADMIN_PASSWORD +psql -X -f sql/cleanup.sql +# Repeat documented bootstrap, migrate twice, grants and seed twice. +export PGUSER=redirects_app PGPASSWORD=$APP_PASSWORD +export REDIRECTS_EXECUTABLE="$PWD/target/release/redirect-links" +export EVIDENCE_DIR=/private/path/acceptance +openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj /CN=UnrelatedAcceptanceCA \ + -keyout /private/path/wrong-ca.key -out /private/path/wrong-ca.pem +export WRONG_CA=/private/path/wrong-ca.pem +cargo test --locked --test cancellation_cloud -- --ignored --nocapture +python3 test/cloud.py +``` + +The separate native Cloud test observes four actual blocked Diesel connections, aborts one awaiting task, verifies admission remains exhausted, then observes all four writes committed and later admission restored. It removes its rows/counters before HTTP cases. The HTTP helper never follows redirects. Cases cover canonical collisions/ownership, actual blocked HTTP transactions with independent progress, account-cap contention, counter rollback after insert failure, actual deferred COMMIT failure, twelve real inserts across 99/100 plus an owner-controlled INT64_MAX row, expiry/disable/revisions, grants/constraints/trusted-role bypass, certificate-specific negatives with positive DNS control, and durable replay of reads after confirmed process exit/restart. + +Held-lock HTTP checks first warm the actual pool for observable independent sessions; this test-only preparation leaves production connection, statement and lock limits unchanged. + +Owner-only fixture triggers, sequence adjustment and maximum-ID/full-account rows accelerate specific boundaries; they do not change runtime grants or imply throughput. Raw certificate diagnostics remain private. The fixture reset is not a production downgrade. + +## Cleanup + +Stop the app. Optional schema destruction requires administrator fields restored after the runtime commands unset them: + +```sh +set -a; source /private/path/setup.env; set +a +export PGUSER=$ADMIN_USER PGPASSWORD=$ADMIN_PASSWORD PGSSLMODE=verify-full +psql -X -f sql/cleanup.sql +clickhousectl cloud postgres delete "$PG_ID" --org-id "$ORG_ID" +clickhousectl cloud postgres list --org-id "$ORG_ID" +``` + +Confirm your exact ID is absent. PUBLIC revocations remain after schema cleanup. + +Primary references: [Actix/Diesel integration](https://actix.rs/docs/databases/), [Actix 4.15 blocking API](https://docs.rs/actix-web/4.15.0/actix_web/web/fn.block.html), [Diesel transaction closure](https://docs.diesel.rs/2.3.x/diesel/connection/trait.Connection.html), [libpq verification and connection options](https://www.postgresql.org/docs/current/libpq-connect.html). diff --git a/applications/redirect-links/rust-toolchain.toml b/applications/redirect-links/rust-toolchain.toml new file mode 100644 index 00000000..b432996f --- /dev/null +++ b/applications/redirect-links/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/applications/redirect-links/sql/bootstrap.sql b/applications/redirect-links/sql/bootstrap.sql new file mode 100644 index 00000000..ef44093a --- /dev/null +++ b/applications/redirect-links/sql/bootstrap.sql @@ -0,0 +1,9 @@ +\set ON_ERROR_STOP on +SELECT format('CREATE ROLE redirects_migration LOGIN PASSWORD %L', :'MIGRATION_PASSWORD') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'redirects_migration') \gexec +SELECT format('CREATE ROLE redirects_app LOGIN PASSWORD %L', :'APP_PASSWORD') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'redirects_app') \gexec +CREATE SCHEMA IF NOT EXISTS redirect_links AUTHORIZATION redirects_migration; +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +SELECT format('REVOKE CREATE,TEMP ON DATABASE %I FROM PUBLIC', current_database()) \gexec +SELECT format('GRANT CONNECT ON DATABASE %I TO redirects_migration,redirects_app', current_database()) \gexec diff --git a/applications/redirect-links/sql/cleanup.sql b/applications/redirect-links/sql/cleanup.sql new file mode 100644 index 00000000..ce0fc11d --- /dev/null +++ b/applications/redirect-links/sql/cleanup.sql @@ -0,0 +1,7 @@ +\set ON_ERROR_STOP on +DROP SCHEMA IF EXISTS redirect_links CASCADE; +SELECT format('DROP OWNED BY %I',rolname) FROM pg_roles +WHERE rolname IN ('redirects_app','redirects_migration') \gexec +DROP ROLE IF EXISTS redirects_app; +DROP ROLE IF EXISTS redirects_migration; +-- Dedicated fixture reset; PUBLIC revocations remain. Not a production downgrade. diff --git a/applications/redirect-links/sql/grants.sql b/applications/redirect-links/sql/grants.sql new file mode 100644 index 00000000..5fa3c083 --- /dev/null +++ b/applications/redirect-links/sql/grants.sql @@ -0,0 +1,7 @@ +\set ON_ERROR_STOP on +GRANT USAGE ON SCHEMA redirect_links TO redirects_app; +GRANT SELECT ON redirect_links.accounts,redirect_links.links TO redirects_app; +GRANT INSERT ON redirect_links.links TO redirects_app; +GRANT UPDATE(link_count) ON redirect_links.accounts TO redirects_app; +GRANT UPDATE(disabled_at,revision) ON redirect_links.links TO redirects_app; +GRANT USAGE ON SEQUENCE redirect_links.links_id_seq TO redirects_app; diff --git a/applications/redirect-links/sql/migrate.sql b/applications/redirect-links/sql/migrate.sql new file mode 100644 index 00000000..c8d87fe9 --- /dev/null +++ b/applications/redirect-links/sql/migrate.sql @@ -0,0 +1,34 @@ +BEGIN; +SELECT pg_advisory_xact_lock(721151); +CREATE TABLE IF NOT EXISTS redirect_links.schema_versions ( + version integer PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() +); +DO $migration$ +BEGIN + IF NOT EXISTS (SELECT FROM redirect_links.schema_versions WHERE version = 1) THEN + CREATE TABLE redirect_links.accounts ( + id text PRIMARY KEY CHECK (id IN ('north','south')), + link_count integer NOT NULL DEFAULT 0 CHECK (link_count BETWEEN 0 AND 20) + ); + CREATE TABLE redirect_links.links ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY CHECK (id > 0), + account text NOT NULL REFERENCES redirect_links.accounts(id), + slug text NOT NULL CONSTRAINT links_slug_unique UNIQUE, + destination text NOT NULL, + expires_at timestamptz, + disabled_at timestamptz, + revision bigint NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'), + CHECK (slug NOT IN ('health','links','admin','api','static')), + CHECK (octet_length(destination) <= 2048 AND destination ~ '^https?://' + AND destination !~ '[[:cntrl:]]'), + CHECK ((revision = 1 AND disabled_at IS NULL) OR (revision = 2 AND disabled_at IS NOT NULL)) + ); + CREATE INDEX links_account_id ON redirect_links.links(account,id DESC); + INSERT INTO redirect_links.schema_versions(version) VALUES (1); + END IF; +END; +$migration$; +COMMIT; diff --git a/applications/redirect-links/sql/seed.sql b/applications/redirect-links/sql/seed.sql new file mode 100644 index 00000000..8dd0e16e --- /dev/null +++ b/applications/redirect-links/sql/seed.sql @@ -0,0 +1,2 @@ +\set ON_ERROR_STOP on +INSERT INTO redirect_links.accounts(id) VALUES ('north'),('south') ON CONFLICT DO NOTHING; diff --git a/applications/redirect-links/src/db.rs b/applications/redirect-links/src/db.rs new file mode 100644 index 00000000..4312a7ad --- /dev/null +++ b/applications/redirect-links/src/db.rs @@ -0,0 +1,223 @@ +use crate::{ + error::ApiError, + gate::BlockingGate, + input::NewLink, + schema::{accounts::dsl as a, links::dsl as l}, +}; +use chrono::{DateTime, SecondsFormat, Utc}; +use diesel::{ + prelude::*, + r2d2::{ConnectionManager, Pool}, + sql_types::{Bool, Nullable, Timestamptz}, +}; +use serde::Serialize; +use std::time::Duration; + +pub type PgPool = Pool>; + +#[derive(Clone)] +pub struct Database { + pool: PgPool, + gate: BlockingGate, +} + +pub fn connection_info(role: &str) -> Result { + let required = + |name: &str| std::env::var(name).map_err(|_| ApiError::invalid("missing_configuration")); + if required("PGUSER")? != role { + return Err(ApiError::invalid("wrong_database_role")); + } + let quote = |text: String| format!("'{}'", text.replace('\\', "\\\\").replace('\'', "\\'")); + let mut result = String::new(); + for (key, env) in [ + ("host", "PGHOST"), + ("port", "PGPORT"), + ("dbname", "PGDATABASE"), + ("user", "PGUSER"), + ("password", "PGPASSWORD"), + ("sslrootcert", "PGSSLROOTCERT"), + ] { + let value = required(env)?; + if value.is_empty() { + return Err(ApiError::invalid("missing_configuration")); + } + result += &format!("{key}={} ", quote(value)); + } + result += "sslmode=verify-full connect_timeout=5 application_name=redirect-links options='-c statement_timeout=4000 -c lock_timeout=2000 -c idle_in_transaction_session_timeout=6000'"; + Ok(result) +} + +impl Database { + pub fn connect(info: String) -> Result { + let pool = Pool::builder() + .max_size(4) + .min_idle(Some(0)) + .connection_timeout(Duration::from_secs(2)) + .idle_timeout(Some(Duration::from_secs(60))) + .max_lifetime(Some(Duration::from_secs(300))) + .build(ConnectionManager::::new(info)) + .map_err(|_| ApiError::unavailable())?; + pool.get_timeout(Duration::from_secs(2)) + .map_err(|_| ApiError::unavailable())?; + Ok(Self { + pool, + gate: BlockingGate::new(4), + }) + } + + pub async fn run(&self, operation: F) -> Result + where + F: FnOnce(&mut PgConnection) -> Result + Send + 'static, + T: Send + 'static, + { + let pool = self.pool.clone(); + self.gate + .run(move || { + let mut connection = pool + .get_timeout(Duration::from_secs(2)) + .map_err(|_| ApiError::unavailable())?; + operation(&mut connection) + }) + .await + } +} + +#[derive(Queryable, Selectable)] +#[diesel(table_name = crate::schema::links)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Link { + pub id: i64, + pub account: String, + pub slug: String, + pub destination: String, + pub expires_at: Option>, + pub disabled_at: Option>, + pub revision: i64, + pub created_at: DateTime, +} + +#[derive(Serialize)] +pub struct LinkView { + pub id: String, + pub slug: String, + pub destination: String, + pub expires_at: Option, + pub disabled_at: Option, + pub revision: String, + pub created_at: String, +} +impl From for LinkView { + fn from(row: Link) -> Self { + let date = |value: DateTime| value.to_rfc3339_opts(SecondsFormat::Micros, true); + Self { + id: row.id.to_string(), + slug: row.slug, + destination: row.destination, + expires_at: row.expires_at.map(date), + disabled_at: row.disabled_at.map(date), + revision: row.revision.to_string(), + created_at: date(row.created_at), + } + } +} + +pub fn create( + conn: &mut PgConnection, + owner: String, + input: NewLink, +) -> Result { + conn.transaction(|conn| { + let count = a::accounts + .filter(a::id.eq(&owner)) + .select(a::link_count) + .for_update() + .first::(conn)?; + if count >= 20 { + return Err(ApiError::conflict("account_full")); + } + diesel::update(a::accounts.filter(a::id.eq(&owner))) + .set(a::link_count.eq(count + 1)) + .execute(conn)?; + let saved = diesel::insert_into(l::links) + .values(( + l::account.eq(owner), + l::slug.eq(input.slug), + l::destination.eq(input.destination), + l::expires_at.eq(input.expires_at), + )) + .returning(Link::as_returning()) + .get_result::(conn)?; + Ok(saved.into()) + }) +} + +pub fn disable( + conn: &mut PgConnection, + owner: String, + slug: String, + expected: i64, +) -> Result { + conn.transaction(|conn| { + let saved = l::links + .filter(l::account.eq(&owner)) + .filter(l::slug.eq(&slug)) + .select(Link::as_select()) + .for_update() + .first::(conn)?; + if saved.disabled_at.is_some() { + if expected == saved.revision || expected == saved.revision - 1 { + return Ok(saved.into()); + } + return Err(ApiError::conflict("revision_conflict")); + } + if saved.revision != expected { + return Err(ApiError::conflict("revision_conflict")); + } + let changed = diesel::update( + l::links + .filter(l::account.eq(owner)) + .filter(l::slug.eq(slug)), + ) + .set(( + l::disabled_at.eq(diesel::dsl::sql::>( + "clock_timestamp()", + )), + l::revision.eq(saved.revision + 1), + )) + .returning(Link::as_returning()) + .get_result::(conn)?; + Ok(changed.into()) + }) +} + +pub fn list( + conn: &mut PgConnection, + owner: String, + limit: i64, + before: Option, +) -> Result, ApiError> { + let mut query = l::links.filter(l::account.eq(owner)).into_boxed(); + if let Some(before) = before { + query = query.filter(l::id.lt(before)); + } + Ok(query + .order(l::id.desc()) + .limit(limit) + .select(Link::as_select()) + .load::(conn)? + .into_iter() + .map(Into::into) + .collect()) +} + +pub fn resolve(conn: &mut PgConnection, slug: String) -> Result, ApiError> { + Ok(l::links + .filter(l::slug.eq(slug)) + .filter(l::disabled_at.is_null()) + .filter(diesel::dsl::sql::( + "(expires_at IS NULL OR expires_at > clock_timestamp())", + )) + .select(l::destination) + .first::(conn) + .optional()?) +} diff --git a/applications/redirect-links/src/error.rs b/applications/redirect-links/src/error.rs new file mode 100644 index 00000000..fa7699a3 --- /dev/null +++ b/applications/redirect-links/src/error.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, ResponseError, http::StatusCode}; +use diesel::result::{DatabaseErrorKind, Error}; +use std::fmt; + +#[derive(Debug)] +pub struct ApiError(pub StatusCode, pub &'static str); + +impl ApiError { + pub fn invalid(code: &'static str) -> Self { + Self(StatusCode::BAD_REQUEST, code) + } + pub fn conflict(code: &'static str) -> Self { + Self(StatusCode::CONFLICT, code) + } + pub fn unavailable() -> Self { + Self(StatusCode::SERVICE_UNAVAILABLE, "unavailable") + } +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.1) + } +} +impl std::error::Error for ApiError {} +impl ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + self.0 + } + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.0) + .insert_header(("Cache-Control", "no-store")) + .json(serde_json::json!({"error": self.1})) + } +} +impl From for ApiError { + fn from(value: Error) -> Self { + match value { + Error::DatabaseError(DatabaseErrorKind::UniqueViolation, info) + if info.constraint_name() == Some("links_slug_unique") => + { + Self::conflict("slug_taken") + } + Error::NotFound => Self(StatusCode::NOT_FOUND, "not_found"), + _ => Self::unavailable(), + } + } +} diff --git a/applications/redirect-links/src/gate.rs b/applications/redirect-links/src/gate.rs new file mode 100644 index 00000000..b182752b --- /dev/null +++ b/applications/redirect-links/src/gate.rs @@ -0,0 +1,68 @@ +use crate::error::ApiError; +use actix_web::web; +use std::sync::Arc; +use tokio::sync::Semaphore; + +#[derive(Clone)] +pub struct BlockingGate(Arc); + +impl BlockingGate { + pub fn new(capacity: usize) -> Self { + Self(Arc::new(Semaphore::new(capacity))) + } + + pub async fn run(&self, operation: F) -> Result + where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, + { + let permit = self + .0 + .clone() + .try_acquire_owned() + .map_err(|_| ApiError::unavailable())?; + web::block(move || { + // The closure owns admission through checkout and SQL. Dropping the HTTP + // future does not release capacity while its blocking work continues. + let _permit = permit; + operation() + }) + .await + .map_err(|_| ApiError::unavailable())? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[actix_web::test] + async fn cancelled_waiter_keeps_actual_work_admitted() { + let gate = BlockingGate::new(1); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = mpsc::channel(); + let running = gate.clone(); + let task = actix_web::rt::spawn(async move { + running + .run(move || { + started.send(()).unwrap(); + wait.recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + Ok(()) + }) + .await + }); + ready.await.unwrap(); + task.abort(); + assert!(gate.run(|| Ok(())).await.is_err()); + release.send(()).unwrap(); + for _ in 0..100 { + if gate.run(|| Ok(())).await.is_ok() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("blocking closure did not release capacity after finishing"); + } +} diff --git a/applications/redirect-links/src/input.rs b/applications/redirect-links/src/input.rs new file mode 100644 index 00000000..1d84644f --- /dev/null +++ b/applications/redirect-links/src/input.rs @@ -0,0 +1,194 @@ +use crate::error::ApiError; +use chrono::{DateTime, Datelike, Utc}; +use serde::Deserialize; +use url::Url; + +pub fn slug(value: &str) -> Result { + if !(3..=40).contains(&value.len()) + || !value.is_ascii() + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + || !value.as_bytes()[0].is_ascii_alphanumeric() + || !value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + { + return Err(ApiError::invalid("invalid_slug")); + } + let canonical = value.to_ascii_lowercase(); + if ["health", "links", "admin", "api", "static"].contains(&canonical.as_str()) { + return Err(ApiError::invalid("reserved_slug")); + } + Ok(canonical) +} + +pub fn destination(value: &str) -> Result { + if value.len() > 2048 || value != value.trim() || value.chars().any(char::is_control) { + return Err(ApiError::invalid("invalid_destination")); + } + let authority = value + .split_once("://") + .map(|(_, rest)| rest.split(['/', '?', '#']).next().unwrap_or("")); + if authority.is_none_or(|value| value.is_empty() || value.contains('@')) { + return Err(ApiError::invalid("invalid_destination")); + } + let parsed = Url::parse(value).map_err(|_| ApiError::invalid("invalid_destination"))?; + if !["http", "https"].contains(&parsed.scheme()) + || parsed.host().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.as_str().len() > 2048 + { + return Err(ApiError::invalid("invalid_destination")); + } + Ok(parsed.to_string()) +} + +pub fn positive(value: &str) -> Result { + if value.is_empty() + || value.len() > 19 + || value.starts_with('0') + || !value.bytes().all(|c| c.is_ascii_digit()) + { + return Err(ApiError::invalid("invalid_number")); + } + value + .parse() + .map_err(|_| ApiError::invalid("invalid_number")) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateInput { + pub slug: String, + pub destination: String, + pub expires_at: Option, +} + +pub struct NewLink { + pub slug: String, + pub destination: String, + pub expires_at: Option>, +} + +impl CreateInput { + pub fn validate(self) -> Result { + let expiry = self + .expires_at + .map(|text| { + if text.len() > 35 { + return Err(ApiError::invalid("invalid_expiry")); + } + let date = DateTime::parse_from_rfc3339(&text) + .map_err(|_| ApiError::invalid("invalid_expiry"))? + .with_timezone(&Utc); + if !(1970..=2100).contains(&date.year()) + || date.timestamp_subsec_nanos() >= 1_000_000_000 + || date.timestamp_subsec_nanos() % 1000 != 0 + { + return Err(ApiError::invalid("invalid_expiry")); + } + Ok(date) + }) + .transpose()?; + Ok(NewLink { + slug: slug(&self.slug)?, + destination: destination(&self.destination)?, + expires_at: expiry, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DisableInput { + pub revision: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Page { + pub limit: Option, + pub before: Option, +} + +impl Page { + pub fn validate(self) -> Result<(i64, Option), ApiError> { + let limit = self + .limit + .as_deref() + .map(positive) + .transpose()? + .unwrap_or(10); + if limit > 20 { + return Err(ApiError::invalid("invalid_limit")); + } + let before = self.before.as_deref().map(positive).transpose()?; + Ok((limit, before)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_slug_and_url() { + assert_eq!(slug("My-Link").unwrap(), "my-link"); + for value in ["ab", "-abc", "abc-", "abc/def", "a\0b", "ABC", "health"] { + assert!(slug(value).is_err()); + } + assert_eq!( + destination("HTTPS://EXAMPLE.COM:443/a").unwrap(), + "https://example.com/a" + ); + for value in [ + "file:///tmp/a", + "https://user@example.com/", + "https://@example.com/", + "https:example.com", + "https://example.com/\n", + "https://example.com/\u{0085}", + " https://example.com", + ] { + assert!(destination(value).is_err()); + } + } + + #[test] + fn numeric_and_expiry_bounds() { + assert_eq!(positive("9223372036854775807").unwrap(), i64::MAX); + for value in ["0", "01", "-1", "9223372036854775808"] { + assert!(positive(value).is_err()); + } + assert!(positive(&"9".repeat(1000)).is_err()); + let good: CreateInput = serde_json::from_str(r#"{"slug":"abc","destination":"https://example.com","expires_at":"2026-10-02T12:00:00+02:00"}"#).unwrap(); + assert_eq!( + good.validate().unwrap().expires_at.unwrap().to_rfc3339(), + "2026-10-02T10:00:00+00:00" + ); + assert!( + serde_json::from_str::( + r#"{"slug":"abc","destination":"https://example.com","owner":"south"}"# + ) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"slug":"\ud800","destination":"https://example.com"}"# + ) + .is_err() + ); + for date in [ + "2025-02-29T00:00:00Z", + "2026-10-02T00:00:00.0000001Z", + "2016-12-31T23:59:60Z", + ] { + let value = CreateInput { + slug: "abc".into(), + destination: "https://example.com".into(), + expires_at: Some(date.into()), + }; + assert!(value.validate().is_err()); + } + } +} diff --git a/applications/redirect-links/src/lib.rs b/applications/redirect-links/src/lib.rs new file mode 100644 index 00000000..3ed8ae4c --- /dev/null +++ b/applications/redirect-links/src/lib.rs @@ -0,0 +1,158 @@ +pub mod db; +pub mod error; +pub mod gate; +pub mod input; +pub mod schema; + +use actix_web::{ + HttpRequest, HttpResponse, + http::{StatusCode, header}, + web, +}; +use db::Database; +use error::ApiError; +use input::{CreateInput, DisableInput, Page}; +use subtle::ConstantTimeEq; + +pub struct State { + pub database: Database, + pub tokens: [(String, String); 2], +} + +fn owner(request: &HttpRequest, state: &State) -> Result { + let supplied = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + for (account, token) in &state.tokens { + let expected = format!("Bearer {token}"); + if bool::from(supplied.as_bytes().ct_eq(expected.as_bytes())) { + return Ok(account.clone()); + } + } + Err(ApiError(StatusCode::UNAUTHORIZED, "unauthorized")) +} + +async fn create( + request: HttpRequest, + state: web::Data, + body: web::Json, +) -> Result { + if !request.query_string().is_empty() { + return Err(ApiError::invalid("invalid_query")); + } + let owner = owner(&request, &state)?; + let input = body.into_inner().validate()?; + let saved = state + .database + .run(move |conn| db::create(conn, owner, input)) + .await?; + Ok(HttpResponse::Created() + .insert_header((header::CACHE_CONTROL, "no-store")) + .json(saved)) +} + +async fn list( + request: HttpRequest, + state: web::Data, + page: web::Query, +) -> Result { + let owner = owner(&request, &state)?; + let (limit, before) = page.into_inner().validate()?; + let rows = state + .database + .run(move |conn| db::list(conn, owner, limit, before)) + .await?; + let next = rows.last().map(|row| row.id.clone()); + Ok(HttpResponse::Ok() + .insert_header((header::CACHE_CONTROL, "no-store")) + .json(serde_json::json!({"rows":rows,"next_before":next}))) +} + +async fn disable( + request: HttpRequest, + state: web::Data, + path: web::Path, + body: web::Json, +) -> Result { + if !request.query_string().is_empty() { + return Err(ApiError::invalid("invalid_query")); + } + let owner = owner(&request, &state)?; + let slug = input::slug(&path)?; + let revision = input::positive(&body.revision)?; + let saved = state + .database + .run(move |conn| db::disable(conn, owner, slug, revision)) + .await?; + Ok(HttpResponse::Ok() + .insert_header((header::CACHE_CONTROL, "no-store")) + .json(saved)) +} + +async fn redirect( + state: web::Data, + path: web::Path, +) -> Result { + let slug = match input::slug(&path) { + Ok(value) => value, + Err(_) => { + return Ok(HttpResponse::NotFound() + .insert_header((header::CACHE_CONTROL, "no-store")) + .finish()); + } + }; + match state + .database + .run(move |conn| db::resolve(conn, slug)) + .await? + { + Some(location) => Ok(HttpResponse::TemporaryRedirect() + .insert_header((header::LOCATION, location)) + .insert_header((header::CACHE_CONTROL, "no-store")) + .finish()), + None => Ok(HttpResponse::NotFound() + .insert_header((header::CACHE_CONTROL, "no-store")) + .finish()), + } +} + +pub fn routes(config: &mut web::ServiceConfig) { + config + .service( + web::resource("/links") + .route(web::get().to(list)) + .route(web::post().to(create)), + ) + .service(web::resource("/links/{slug}/disable").route(web::post().to(disable))) + .service( + web::resource("/r/{slug}") + .app_data(web::PathConfig::default().error_handler(|_, _| { + actix_web::error::InternalError::from_response( + "invalid_path", + HttpResponse::NotFound() + .insert_header((header::CACHE_CONTROL, "no-store")) + .finish(), + ) + .into() + })) + .route(web::get().to(redirect)) + .route(web::head().to(redirect)), + ); +} + +pub fn json_config() -> web::JsonConfig { + web::JsonConfig::default() + .limit(4096) + .error_handler(|error, _| { + let status = match error { + actix_web::error::JsonPayloadError::OverflowKnownLength { .. } + | actix_web::error::JsonPayloadError::Overflow { .. } => { + StatusCode::PAYLOAD_TOO_LARGE + } + _ => StatusCode::BAD_REQUEST, + }; + ApiError(status, "invalid_json").into() + }) +} diff --git a/applications/redirect-links/src/main.rs b/applications/redirect-links/src/main.rs new file mode 100644 index 00000000..d81cceee --- /dev/null +++ b/applications/redirect-links/src/main.rs @@ -0,0 +1,119 @@ +use actix_web::{App, HttpResponse, HttpServer, web}; +use diesel::{Connection, PgConnection, connection::SimpleConnection}; +use redirect_links::{ + State, + db::{Database, connection_info}, + error::ApiError, + json_config, routes, +}; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + if let Err(error) = run().await { + eprintln!("Startup failed; check private configuration ({})", error.1); + std::process::exit(1); + } + Ok(()) +} + +async fn run() -> Result<(), ApiError> { + let arguments: Vec = std::env::args().skip(1).collect(); + if arguments == ["migrate"] { + let info = connection_info("redirects_migration")?; + web::block(move || { + let mut conn = PgConnection::establish(&info).map_err(|_| ApiError::unavailable())?; + conn.batch_execute(include_str!("../sql/migrate.sql"))?; + Ok::<_, ApiError>(()) + }) + .await + .map_err(|_| ApiError::unavailable())??; + println!("Explicit migration complete"); + return Ok(()); + } + if arguments == ["--check-db"] { + let info = connection_info("redirects_app")?; + // Explicit diagnostic CLI mode for private certificate-control evidence. + let result = web::block(move || PgConnection::establish(&info)) + .await + .map_err(|_| ApiError::unavailable())?; + match result { + Ok(_) => println!("Verified Diesel connection"), + Err(error) => { + eprintln!("{error}"); + return Err(ApiError::unavailable()); + } + } + return Ok(()); + } + let preflight = arguments == ["--preflight"]; + if !arguments.is_empty() && !preflight { + return Err(ApiError::invalid("invalid_arguments")); + } + let state = if preflight { + None + } else { + let info = connection_info("redirects_app")?; + let database = web::block(move || Database::connect(info)) + .await + .map_err(|_| ApiError::unavailable())??; + let token = |name: &str| { + let value = std::env::var(name).map_err(|_| ApiError::invalid("missing_token"))?; + if !(32..=128).contains(&value.len()) + || !value.bytes().all(|c| (b'!'..=b'~').contains(&c)) + { + return Err(ApiError::invalid("invalid_token")); + } + Ok(value) + }; + let north = token("NORTH_TOKEN")?; + let south = token("SOUTH_TOKEN")?; + if north == south { + return Err(ApiError::invalid("tokens_must_differ")); + } + Some(web::Data::new(State { + database, + tokens: [("north".into(), north), ("south".into(), south)], + })) + }; + let port = std::env::var("PORT") + .unwrap_or_else(|_| "4000".into()) + .parse::() + .map_err(|_| ApiError::invalid("invalid_port"))?; + if port < 1024 { + return Err(ApiError::invalid("invalid_port")); + } + HttpServer::new(move || { + let app = App::new() + .app_data(json_config()) + .app_data( + web::QueryConfig::default() + .error_handler(|_, _| ApiError::invalid("invalid_query").into()), + ) + .default_service(web::route().to(|| async { + HttpResponse::NotFound() + .insert_header(("Cache-Control", "no-store")) + .finish() + })) + .route( + "/health", + web::get() + .to(|| async { HttpResponse::Ok().json(serde_json::json!({"status":"up"})) }), + ); + if let Some(state) = &state { + app.app_data(state.clone()).configure(routes) + } else { + app + } + }) + .workers(2) + .worker_max_blocking_threads(4) + .max_connections(32) + .client_request_timeout(std::time::Duration::from_secs(5)) + .keep_alive(std::time::Duration::from_secs(5)) + .shutdown_timeout(10) + .bind(("127.0.0.1", port)) + .map_err(|_| ApiError::unavailable())? + .run() + .await + .map_err(|_| ApiError::unavailable()) +} diff --git a/applications/redirect-links/src/schema.rs b/applications/redirect-links/src/schema.rs new file mode 100644 index 00000000..644eadcb --- /dev/null +++ b/applications/redirect-links/src/schema.rs @@ -0,0 +1,22 @@ +diesel::table! { + redirect_links.accounts (id) { + id -> Text, + link_count -> Int4, + } +} + +diesel::table! { + redirect_links.links (id) { + id -> Int8, + account -> Text, + slug -> Text, + destination -> Text, + expires_at -> Nullable, + disabled_at -> Nullable, + revision -> Int8, + created_at -> Timestamptz, + } +} + +diesel::joinable!(links -> accounts (account)); +diesel::allow_tables_to_appear_in_same_query!(accounts, links); diff --git a/applications/redirect-links/test/cloud.py b/applications/redirect-links/test/cloud.py new file mode 100644 index 00000000..76b9e8dd --- /dev/null +++ b/applications/redirect-links/test/cloud.py @@ -0,0 +1,334 @@ +"""Destructive acceptance on your own dedicated Cloud fixture. Never follows redirects.""" +import concurrent.futures +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request + +EXECUTABLE = os.environ['REDIRECTS_EXECUTABLE'] +OUTPUT = Path(os.environ.get('EVIDENCE_DIR', '.')) +OUTPUT.mkdir(parents=True, exist_ok=True) +RUNTIME_FIELDS = ['PGHOST', 'PGPORT', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', + 'PGSSLROOTCERT', 'NORTH_TOKEN', 'SOUTH_TOKEN', 'PORT'] +RUNTIME = {key: os.environ[key] for key in RUNTIME_FIELDS} +server = None +server_log = None + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + return None + + +opener = urllib.request.build_opener(NoRedirect) + + +def call(path, body=None, south=False, method=None, raw=None, auth=True, media='application/json'): + data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None) + headers = {'Content-Type': media} + if auth: + headers['Authorization'] = 'Bearer ' + RUNTIME['SOUTH_TOKEN' if south else 'NORTH_TOKEN'] + request = urllib.request.Request('http://127.0.0.1:' + RUNTIME['PORT'] + path, + data=data, headers=headers, method=method) + try: + response = opener.open(request, timeout=12) + except urllib.error.HTTPError as error: + response = error + text = response.read() + try: + value = json.loads(text) + except json.JSONDecodeError: + value = {} + return response.status, {name.lower(): value for name, value in response.headers.items()}, value + + +def database(sql, role='owner', check=True): + env = dict(os.environ, PGSSLMODE='verify-full') + if role == 'owner': + env.update(PGUSER='redirects_migration', PGPASSWORD=os.environ['MIGRATION_PASSWORD']) + elif role == 'admin': + env.update(PGUSER=os.environ['ADMIN_USER'], PGPASSWORD=os.environ['ADMIN_PASSWORD']) + elif role != 'runtime': + raise ValueError('unknown control role') + result = subprocess.run(['psql', '-X', '-qAt', '-v', 'ON_ERROR_STOP=1', '-v', 'VERBOSITY=verbose'], + input=sql, text=True, capture_output=True, env=env, timeout=20) + if check and result.returncode: + raise RuntimeError(result.stderr) + return result + + +def create(slug, destination='https://example.invalid/guide', south=False, expiry=None): + body = {'slug': slug, 'destination': destination} + if expiry is not None: + body['expires_at'] = expiry + return call('/links', body, south=south) + + +def start(): + global server, server_log + server_log = open(OUTPUT / f'server-{time.time_ns()}.log', 'w') + server = subprocess.Popen([EXECUTABLE], env=RUNTIME, stdout=server_log, stderr=subprocess.STDOUT) + for _ in range(100): + if server.poll() is not None: + raise RuntimeError('runtime startup failed; inspect private log') + try: + if call('/health', auth=False)[0] == 200: + return + except OSError: + pass + time.sleep(.1) + raise RuntimeError('runtime did not become ready') + + +def stop(): + if server is not None and server.poll() is None: + server.send_signal(signal.SIGTERM) + assert server.wait(timeout=15) == 0 + if server_log is not None: + server_log.close() + + +def warm_pool(): + # Cold TLS connection establishment is not part of the short held-lock barrier. + # Warm the real runtime pool with bounded reads; retain production timeouts. + for _ in range(6): + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + responses = list(executor.map(lambda _: call('/links'), range(4))) + assert all(response[0] in (200, 503) for response in responses) + count = int(database("SELECT count(*) FROM pg_stat_activity WHERE application_name='redirect-links'", role='admin').stdout) + if count == 4: + assert call('/links')[0] == 200 + print('Warm real runtime pool: four sessions') + return + raise AssertionError('four runtime pool sessions did not become ready') + + +def held_pair(first, second): + warm_pool() + env = dict(os.environ, PGUSER=os.environ['ADMIN_USER'], PGPASSWORD=os.environ['ADMIN_PASSWORD'], + PGSSLMODE='verify-full') + holder = subprocess.Popen(['psql', '-X', '-qAt', '-v', 'ON_ERROR_STOP=1'], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) + holder.stdin.write("BEGIN; SELECT id FROM redirect_links.accounts WHERE id='north' FOR UPDATE;\n") + holder.stdin.flush() + assert holder.stdout.readline().strip() == 'north' + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + a = executor.submit(create, first) + b = executor.submit(create, second) + try: + for _ in range(60): + rows = database("""SELECT pg_stat_clear_snapshot(); + SELECT count(*) FROM pg_stat_activity WHERE application_name='redirect-links' + AND cardinality(pg_blocking_pids(pid))>0""", role='admin').stdout.splitlines() + if rows and rows[-1] == '2': + break + time.sleep(.01) + else: + raise AssertionError('two actual Diesel HTTP sessions not observed blocked') + assert call('/health', auth=False)[0] == 200 + assert call('/links')[0] == 200 + assert holder.poll() is None and not a.done() and not b.done() + print('Two actual blocked writers; health and bounded DB read finish before lock release') + finally: + holder.stdin.write('COMMIT;\n') + holder.stdin.flush() + holder.stdin.close() + assert holder.wait(timeout=10) == 0 + return a.result(timeout=12), b.result(timeout=12) + + +def canonical_and_scope(): + status, _, saved = create('My-Guide', 'HTTPS://EXAMPLE.COM:443/a') + assert status == 201 and saved['slug'] == 'my-guide' and saved['destination'] == 'https://example.com/a' + for method in ['GET', 'HEAD']: + status, headers, body = call('/r/MY-GUIDE', auth=False, method=method) + assert status == 307 and headers['location'] == saved['destination'] + assert headers['cache-control'] == 'no-store' and body == {} + assert create('my-GUIDE', saved['destination'])[0] == 409 + assert call('/links', south=True)[2]['rows'] == [] + assert call('/links/my-guide/disable', {'revision': '1'}, south=True)[0] == 404 + assert call('/links', {'slug': 'forged-owner', 'destination': 'https://example.com', 'owner': 'south'})[0] == 400 + for slug in ['ab', '-abc', 'abc-', 'abc/def', 'a\x00b', 'ABC', 'health', 'a' * 41]: + assert create(slug)[0] == 400 + for url in ['file:///tmp/a', 'https://user@example.com', 'https://@example.com', 'https:example.com', + 'https://example.com/\n', 'https://example.com/\u0085', ' https://example.com', 'https://example.com/' + 'a' * 2048]: + assert create('bad-url', url)[0] == 400 + assert call('/links', raw=br'{"slug":"\ud800","destination":"https://example.com"}')[0] == 400 + assert call('/links', raw=b'{"slug":"abc","slug":"def","destination":"https://example.com"}')[0] == 400 + assert call('/links', raw=b'{' + b' ' * 5000 + b'}')[0] == 413 + assert call('/links', {'slug': 'abc', 'destination': 'https://example.com'}, media='application/jsonp')[0] == 400 + assert call('/links', auth=False)[0] == 401 + for query in ['limit=21', 'before=0', 'before=9223372036854775808', 'owner=south']: + assert call('/links?' + query)[0] == 400 + for path in ['/r/unknown', '/r/ab', '/r/%FF', '/r/']: + status, headers, _ = call(path, auth=False) + assert status == 404 and headers['cache-control'] == 'no-store' + + +def concurrent_collision(): + first, second = held_pair('Same-Slug', 'same-slug') + print('Contending HTTP statuses:', first[0], second[0]) + assert sorted([first[0], second[0]]) == [201, 409] + assert database("SELECT count(*) FROM redirect_links.links WHERE slug='same-slug'").stdout.strip() == '1' + assert database("SELECT link_count=(SELECT count(*) FROM redirect_links.links WHERE account='north') FROM redirect_links.accounts WHERE id='north'").stdout.strip() == 't' + + +def cap_contention(): + count = int(database("SELECT count(*) FROM redirect_links.links WHERE account='north'").stdout) + database(f"""BEGIN; + INSERT INTO redirect_links.links(account,slug,destination) + SELECT 'north','cap-fill-'||n,'https://example.invalid/cap' FROM generate_series(1,{19-count}) n; + UPDATE redirect_links.accounts SET link_count=19 WHERE id='north'; COMMIT;""") + first, second = held_pair('cap-final-a', 'cap-final-b') + print('Contending HTTP statuses:', first[0], second[0]) + assert sorted([first[0], second[0]]) == [201, 409] + assert database("SELECT link_count FROM redirect_links.accounts WHERE id='north'").stdout.strip() == '20' + assert database("SELECT count(*) FROM redirect_links.links WHERE account='north'").stdout.strip() == '20' + database("""BEGIN; DELETE FROM redirect_links.links WHERE account='north' AND slug LIKE 'cap-%'; + UPDATE redirect_links.accounts SET link_count=(SELECT count(*) FROM redirect_links.links WHERE account='north') WHERE id='north'; COMMIT;""") + + +def second_write_rollback(): + before = database("SELECT link_count FROM redirect_links.accounts WHERE id='north'").stdout + database("""CREATE FUNCTION redirect_links.reject_insert() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.slug='rollback-check' THEN RAISE EXCEPTION 'fixture insert failure'; END IF; RETURN NEW; END $$; + CREATE TRIGGER reject_insert BEFORE INSERT ON redirect_links.links FOR EACH ROW EXECUTE FUNCTION redirect_links.reject_insert();""") + try: + assert create('rollback-check')[0] == 503 + assert database("SELECT link_count FROM redirect_links.accounts WHERE id='north'").stdout == before + assert database("SELECT count(*) FROM redirect_links.links WHERE slug='rollback-check'").stdout.strip() == '0' + finally: + database('DROP TRIGGER reject_insert ON redirect_links.links; DROP FUNCTION redirect_links.reject_insert();') + assert create('rollback-check')[0] == 201 + + +def deferred_commit(): + before = database("SELECT link_count FROM redirect_links.accounts WHERE id='north'").stdout + database("""CREATE FUNCTION redirect_links.reject_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.slug='commit-check' THEN RAISE EXCEPTION 'fixture deferred failure'; END IF; RETURN NEW; END $$; + CREATE CONSTRAINT TRIGGER reject_commit AFTER INSERT ON redirect_links.links DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION redirect_links.reject_commit();""") + try: + assert create('commit-check')[0] == 503 + assert database("SELECT link_count FROM redirect_links.accounts WHERE id='north'").stdout == before + assert database("SELECT count(*) FROM redirect_links.links WHERE slug='commit-check'").stdout.strip() == '0' + finally: + database('DROP TRIGGER reject_commit ON redirect_links.links; DROP FUNCTION redirect_links.reject_commit();') + assert create('commit-check')[0] == 201 + + +def numeric_pages_and_max_id(): + start_id = 98 + assert database(f'SELECT count(*) FROM redirect_links.links WHERE id BETWEEN {start_id} AND {start_id+11}').stdout.strip() == '0' + # Owner-only sequence acceleration puts actual HTTP inserts across the 99/100 boundary. + database(f'ALTER SEQUENCE redirect_links.links_id_seq RESTART WITH {start_id}') + created = [] + for index in range(12): + status, _, row = create(f'page-{index}') + assert status == 201 + created.append(row['id']) + assert created == [str(n) for n in range(start_id, start_id+12)] + database("""BEGIN; INSERT INTO redirect_links.links(id,account,slug,destination) OVERRIDING SYSTEM VALUE + VALUES(9223372036854775807,'north','max-id','https://example.invalid/max'); + UPDATE redirect_links.accounts SET link_count=link_count+1 WHERE id='north'; COMMIT;""") + expected = database("SELECT id FROM redirect_links.links WHERE account='north' ORDER BY redirect_links.links.id DESC").stdout.splitlines() + seen, before = [], '' + for _ in range(12): + status, _, page = call('/links?limit=2' + ('&before=' + before if before else '')) + assert status == 200 + if not page['rows']: + break + seen.extend(row['id'] for row in page['rows']) + before = page['next_before'] + assert seen == expected and len(set(seen)) == len(seen) + assert seen[0] == '9223372036854775807' and set(created).issubset(seen) + print('Actual numeric pages:', seen, '; first page includes INT64_MAX') + + +def expiry_and_disable(): + assert call('/links/my-guide/disable', {'revision': '9'})[0] == 409 + assert call('/links/my-guide/disable', {'revision': 1})[0] == 400 + status, _, disabled = call('/links/my-guide/disable', {'revision': '1'}) + assert status == 200 and disabled['revision'] == '2' + for revision in ['1', '2']: + assert call('/links/my-guide/disable', {'revision': revision})[2] == disabled + assert call('/links/my-guide/disable', {'revision': '3'})[0] == 409 + for method in ['GET', 'HEAD']: + status, headers, _ = call('/r/my-guide', auth=False, method=method) + assert status == 404 and 'location' not in headers and headers['cache-control'] == 'no-store' + assert create('expired-link', south=True, expiry='1970-01-01T00:00:00Z')[0] == 201 + assert call('/r/expired-link', auth=False)[0] == 404 + assert create('boundary-link', south=True, expiry='2050-01-01T00:00:00Z')[0] == 201 + assert call('/r/boundary-link', auth=False)[0] == 307 + database("UPDATE redirect_links.links SET expires_at=clock_timestamp() WHERE slug='boundary-link'") + assert call('/r/boundary-link', auth=False)[0] == 404 + + +def permissions(): + for statement in ["UPDATE redirect_links.links SET destination='https://example.com'", "UPDATE redirect_links.links SET account='south'", + "UPDATE redirect_links.links SET slug='other'", "DELETE FROM redirect_links.links", "UPDATE redirect_links.accounts SET id='other'", + 'CREATE TABLE redirect_links.forbidden(id integer)', 'CREATE SCHEMA forbidden', 'CREATE TEMP TABLE forbidden(id integer)', + 'SELECT * FROM redirect_links.schema_versions']: + result = database(statement, role='runtime', check=False) + assert result.returncode and '42501' in result.stderr + for statement, code in [("INSERT INTO redirect_links.links(account,slug,destination) VALUES('north','Bad-Slug','https://example.com')", '23514'), + ("INSERT INTO redirect_links.links(account,slug,destination) VALUES('other','bad-fk','https://example.com')", '23503'), + ("INSERT INTO redirect_links.links(account,slug,destination) VALUES('south','my-guide','https://example.com')", '23505')]: + result = database(statement, role='runtime', check=False) + assert result.returncode and code in result.stderr + if code == '23505': + assert 'links_slug_unique' in result.stderr + before = database("SELECT link_count FROM redirect_links.accounts WHERE id='south'").stdout + database("INSERT INTO redirect_links.links(account,slug,destination) VALUES('south','direct-write','https://example.invalid/trusted')", role='runtime') + assert database("SELECT link_count FROM redirect_links.accounts WHERE id='south'").stdout == before + assert database("SELECT count(*) FROM redirect_links.links WHERE slug='direct-write'").stdout.strip() == '1' + print('Trusted runtime direct SQL can bypass the application counter protocol') + + +def tls(): + def probe(name, overrides): + result = subprocess.run([EXECUTABLE, '--check-db'], env=dict(RUNTIME, **overrides), text=True, + capture_output=True, timeout=12) + (OUTPUT / name).write_text(result.stdout + result.stderr) + return result + wrong_ca = probe('tls-wrong-ca.log', {'PGSSLROOTCERT': os.environ['WRONG_CA']}) + assert wrong_ca.returncode and 'certificate verify failed' in wrong_ca.stderr + address = socket.getaddrinfo(RUNTIME['PGHOST'], int(RUNTIME['PGPORT']), type=socket.SOCK_STREAM)[0][4][0] + wrong_host = probe('tls-wrong-host.log', {'PGHOST': address}) + assert wrong_host.returncode and 'does not match host name' in wrong_host.stderr + positive = probe('tls-positive.log', {}) + assert positive.returncode == 0 and 'Verified Diesel connection' in positive.stdout + + +def restart(): + status, _, saved = create('restart-link', south=True) + assert status == 201 + old = server.pid + stop() + assert server.poll() == 0 + start() + assert server.pid != old + rows = call('/links', south=True)[2]['rows'] + assert next(row for row in rows if row['slug'] == 'restart-link') == saved + status, headers, _ = call('/r/restart-link', auth=False) + assert status == 307 and headers['location'] == saved['destination'] + assert create('restart-link', south=True)[0] == 409 # Creation has no retained request/replay key. + + +if __name__ == '__main__': + print('Actual PostgreSQL:', database('SHOW server_version').stdout.strip()) + start() + try: + cases = [canonical_and_scope, concurrent_collision, cap_contention, second_write_rollback, + deferred_commit, numeric_pages_and_max_id, expiry_and_disable, permissions, tls, restart] + for case in cases: + case() + print('PASS', case.__name__) + print('All', len(cases), 'HTTP/Cloud cases passed') + finally: + stop() diff --git a/applications/redirect-links/test/preflight.py b/applications/redirect-links/test/preflight.py new file mode 100644 index 00000000..dbfb9d0a --- /dev/null +++ b/applications/redirect-links/test/preflight.py @@ -0,0 +1,30 @@ +"""Exercise the compiled listener and orderly exit without database credentials.""" +import json +import os +import signal +import subprocess +import sys +import time +import urllib.request + +process = subprocess.Popen([sys.argv[1], '--preflight'], env=dict(os.environ, PORT='4100')) +try: + for attempt in range(100): + if process.poll() is not None: + raise RuntimeError('preflight listener exited before readiness') + try: + with urllib.request.urlopen('http://127.0.0.1:4100/health', timeout=1) as response: + assert response.status == 200 + assert json.load(response) == {'status': 'up'} + break + except OSError: + time.sleep(.05) + else: + raise RuntimeError('preflight listener did not become ready') + process.send_signal(signal.SIGTERM) + assert process.wait(timeout=10) == 0 + print('PASS compiled loopback readiness and SIGTERM exit') +finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) diff --git a/applications/redirect-links/tests/cancellation_cloud.rs b/applications/redirect-links/tests/cancellation_cloud.rs new file mode 100644 index 00000000..dfb88011 --- /dev/null +++ b/applications/redirect-links/tests/cancellation_cloud.rs @@ -0,0 +1,118 @@ +//! Destructive control on a freshly seeded dedicated Cloud fixture, before HTTP cases. +use redirect_links::{ + db::{self, Database}, + input::NewLink, +}; +use std::{ + io::{BufRead, BufReader, Write}, + process::{Command, Stdio}, + time::Duration, +}; + +fn admin() -> Command { + let mut command = Command::new("psql"); + command + .args(["-X", "-qAt", "-v", "ON_ERROR_STOP=1"]) + .env("PGUSER", std::env::var("ADMIN_USER").unwrap()) + .env("PGPASSWORD", std::env::var("ADMIN_PASSWORD").unwrap()) + .env("PGSSLMODE", "verify-full"); + command +} + +fn sql(text: &str) -> String { + let result = admin().args(["-c", text]).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() +} + +#[actix_web::test] +#[ignore = "requires freshly seeded dedicated Cloud fixture and private admin control"] +async fn cancelled_database_waiter_retains_capacity_and_can_commit() { + let database = Database::connect(db::connection_info("redirects_app").unwrap()).unwrap(); + let mut holder = admin() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = holder.stdin.take().unwrap(); + input + .write_all(b"BEGIN; SELECT id FROM redirect_links.accounts WHERE id='north' FOR UPDATE;\n") + .unwrap(); + input.flush().unwrap(); + let mut output = BufReader::new(holder.stdout.take().unwrap()); + let mut line = String::new(); + output.read_line(&mut line).unwrap(); + assert_eq!(line.trim(), "north"); + + let mut tasks = Vec::new(); + for index in 0..4 { + let database = database.clone(); + tasks.push(actix_web::rt::spawn(async move { + database + .run(move |conn| { + db::create( + conn, + "north".into(), + NewLink { + slug: format!("cancel-{index}"), + destination: "https://example.invalid/cancel".into(), + expires_at: None, + }, + ) + }) + .await + })); + } + let mut observed = false; + for _ in 0..60 { + let count = sql( + "SELECT pg_stat_clear_snapshot(); SELECT count(*) FROM pg_stat_activity WHERE application_name='redirect-links' AND cardinality(pg_blocking_pids(pid))>0", + ); + if count.lines().last() == Some("4") { + observed = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + observed, + "four actual Diesel connections must be observed waiting on control lock" + ); + let cancelled = tasks.remove(0); + cancelled.abort(); + assert!(matches!(cancelled.await, Err(error) if error.is_cancelled())); + assert!( + database.run(|_| Ok(())).await.is_err(), + "cancelled waiter must not free admission before SQL finishes" + ); + + input.write_all(b"COMMIT;\n").unwrap(); + input.flush().unwrap(); + drop(input); + assert!(holder.wait().unwrap().success()); + for task in tasks { + task.await.unwrap().unwrap(); + } + for _ in 0..60 { + if sql("SELECT count(*) FROM redirect_links.links WHERE slug LIKE 'cancel-%'").trim() == "4" + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + sql("SELECT count(*) FROM redirect_links.links WHERE slug LIKE 'cancel-%'").trim(), + "4" + ); + assert!(database.run(|_| Ok(())).await.is_ok()); + println!( + "Four actual blocked Diesel sessions; aborted waiter retains capacity; all four writes commit; later admission succeeds" + ); + sql( + "BEGIN; DELETE FROM redirect_links.links WHERE slug LIKE 'cancel-%'; UPDATE redirect_links.accounts SET link_count=0 WHERE id='north'; COMMIT;", + ); +}