From fe410fe046efcf630df1540470d584b8f6a61ea6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 1 Oct 2026 12:34:38 -0700 Subject: [PATCH 01/11] Ignore tests directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5cd651cc..c6a62304 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ Desktop.ini *.orig *.rej +# Local tests. +tests/ + # Build output, dependency caches, coverage, and profiling output. target/ node_modules/ From 16f4b91753b181b6ee23873db3bf0368d9677961 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 1 Oct 2026 16:40:16 -0700 Subject: [PATCH 02/11] Migrate non-video media processing to Rust Raise the minimum Rust version to 1.99 and move images, HEIF stills, container probing, common audio waveforms, and bounded PDF previews to Rust implementations. Retain FFmpeg for video and validated unsupported audio compatibility, with resource limits and atomic publication. Include regression fixtures, Chromium/no-JS coverage, and the final audit report. Preserve PDF fixture bytes with Git attributes. Local Rust, media, and real-video browser checks passed. In-band HE-AAC and chained/multiplexed Speex compatibility remain limited; Docker build/runtime validation is blocked by the unavailable Rust 1.99 base image. This commit does not certify production readiness. --- .gitattributes | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 4 +- .gitignore | 9 + Cargo.lock | 567 ++++++++++++++++- Cargo.toml | 18 +- Dockerfile | 6 +- README.md | 8 +- SETUP.md | 30 +- docs/admin-redesign-coverage.md | 1 - docs/containers.md | 2 +- docs/non-video-media-migration.md | 360 +++++++++++ docs/strict-lint-exceptions.tsv | 79 ++- src/banner.rs | 102 +-- src/config.rs | 13 +- src/config/admin.rs | 1 - src/config/admin/runtime.rs | 31 +- src/config/template.rs | 2 - src/detect.rs | 67 +- src/handlers/admin/mod.rs | 24 +- src/handlers/board/access_preferences.rs | 4 +- src/handlers/board/create_thread.rs | 2 - src/handlers/board/media.rs | 79 ++- src/handlers/mod.rs | 24 +- src/handlers/posting.rs | 20 +- src/handlers/setup.rs | 8 +- src/handlers/thread.rs | 14 +- src/media/audio.rs | 726 ++++++++++++++++++++++ src/media/convert.rs | 56 +- src/media/ffmpeg.rs | 261 +------- src/media/heif.rs | 277 +++++++++ src/media/images.rs | 307 +++++++++ src/media/images/tests.rs | 303 +++++++++ src/media/mod.rs | 40 +- src/media/pdf.rs | 358 +++++++++++ src/media/probe.rs | 505 +++++++++++++++ src/media/probe/speex.rs | 195 ++++++ src/media/thumbnail.rs | 513 +++------------ src/middleware/state.rs | 2 - src/pending_fs.rs | 2 +- src/server/server.rs | 20 +- src/templates/admin.rs | 17 +- src/templates/admin/maintenance.rs | 9 +- src/templates/admin/network.rs | 1 - src/templates/admin/site_health.rs | 1 - src/test_fixtures.rs | 1 - src/utils/files/storage.rs | 676 +++++--------------- src/workers/mod.rs | 305 +++++---- tests/cli_runtime.rs | 1 - tests/e2e/README.md | 11 +- tests/e2e/helpers.ts | 22 +- tests/e2e/media-toolchain-check.mjs | 22 +- tests/e2e/media-toolchain.spec.ts | 14 +- tests/e2e/non-video-media.spec.ts | 28 + tests/e2e/phase3-media-runtime.spec.ts | 15 +- tests/e2e/upload-regressions.spec.ts | 27 +- tests/e2e/upload-validation.spec.ts | 99 ++- tests/fixtures/media/README.md | 17 + tests/fixtures/media/ac3.m4a | Bin 0 -> 2245 bytes tests/fixtures/media/alpha.heic | Bin 0 -> 779 bytes tests/fixtures/media/audio.mkv | Bin 0 -> 2714 bytes tests/fixtures/media/audio.webm | Bin 0 -> 3012 bytes tests/fixtures/media/compressed.pdf | Bin 0 -> 463 bytes tests/fixtures/media/he-aac.m4a | Bin 0 -> 2230 bytes tests/fixtures/media/irot90.heic | Bin 0 -> 781 bytes tests/fixtures/media/multiple-streams.mp4 | Bin 0 -> 7625 bytes tests/fixtures/media/phone-hdr-LICENSE | 23 + tests/fixtures/media/phone-hdr.heic | Bin 0 -> 238982 bytes tests/fixtures/media/photo-LICENSE-APACHE | 201 ++++++ tests/fixtures/media/photo-LICENSE-MIT | 21 + tests/fixtures/media/photo.heic | Bin 0 -> 55438 bytes tests/fixtures/media/simple.pdf | Bin 0 -> 678 bytes tests/fixtures/media/speech-mode.opus | Bin 0 -> 457 bytes tests/fixtures/media/surround.opus | Bin 0 -> 9044 bytes tests/fixtures/media/text.pdf | Bin 0 -> 581 bytes tests/fixtures/media/tone-alac.m4a | Bin 0 -> 3008 bytes tests/fixtures/media/tone.aac | Bin 0 -> 2283 bytes tests/fixtures/media/tone.flac | Bin 0 -> 10352 bytes tests/fixtures/media/tone.m4a | Bin 0 -> 3068 bytes tests/fixtures/media/tone.mp3 | Bin 0 -> 2540 bytes tests/fixtures/media/tone.ogg | Bin 0 -> 4700 bytes tests/fixtures/media/tone.opus | Bin 0 -> 2587 bytes tests/fixtures/media/tone.spx | Bin 0 -> 692 bytes tests/fixtures/media/tone.wav | Bin 0 -> 23118 bytes tests/fixtures/media/video.mkv | Bin 0 -> 1719 bytes tests/fixtures/media/video.mp4 | Bin 0 -> 1945 bytes tests/fixtures/media/video.webm | Bin 0 -> 1072 bytes 88 files changed, 4684 insertions(+), 1877 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/non-video-media-migration.md create mode 100644 src/media/audio.rs create mode 100644 src/media/heif.rs create mode 100644 src/media/images.rs create mode 100644 src/media/images/tests.rs create mode 100644 src/media/pdf.rs create mode 100644 src/media/probe.rs create mode 100644 src/media/probe/speex.rs create mode 100644 tests/e2e/non-video-media.spec.ts create mode 100644 tests/fixtures/media/README.md create mode 100644 tests/fixtures/media/ac3.m4a create mode 100644 tests/fixtures/media/alpha.heic create mode 100644 tests/fixtures/media/audio.mkv create mode 100644 tests/fixtures/media/audio.webm create mode 100644 tests/fixtures/media/compressed.pdf create mode 100644 tests/fixtures/media/he-aac.m4a create mode 100644 tests/fixtures/media/irot90.heic create mode 100644 tests/fixtures/media/multiple-streams.mp4 create mode 100644 tests/fixtures/media/phone-hdr-LICENSE create mode 100644 tests/fixtures/media/phone-hdr.heic create mode 100644 tests/fixtures/media/photo-LICENSE-APACHE create mode 100644 tests/fixtures/media/photo-LICENSE-MIT create mode 100644 tests/fixtures/media/photo.heic create mode 100644 tests/fixtures/media/simple.pdf create mode 100644 tests/fixtures/media/speech-mode.opus create mode 100644 tests/fixtures/media/surround.opus create mode 100644 tests/fixtures/media/text.pdf create mode 100644 tests/fixtures/media/tone-alac.m4a create mode 100644 tests/fixtures/media/tone.aac create mode 100644 tests/fixtures/media/tone.flac create mode 100644 tests/fixtures/media/tone.m4a create mode 100644 tests/fixtures/media/tone.mp3 create mode 100644 tests/fixtures/media/tone.ogg create mode 100644 tests/fixtures/media/tone.opus create mode 100644 tests/fixtures/media/tone.spx create mode 100644 tests/fixtures/media/tone.wav create mode 100644 tests/fixtures/media/video.mkv create mode 100644 tests/fixtures/media/video.mp4 create mode 100644 tests/fixtures/media/video.webm diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fb0c87fb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve PDF fixture bytes, including fixed-width cross-reference records. +tests/fixtures/media/*.pdf binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 389eda7b..6cc69c7e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -98,7 +98,7 @@ body: id: data_involvement attributes: label: Database or media involvement - description: State whether SQLite, migrations, uploads, ffmpeg/ffprobe, boards, backups, or restore is involved. Do not upload real data. + description: State whether SQLite, migrations, uploads, media processing, FFmpeg video, boards, backups, or restore is involved. Do not upload real data. placeholder: "SQLite involved; reproduced on a new disposable database. No media involved." validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33400b32..02bcd6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts @@ -68,7 +68,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 - name: Cache Rust artifacts uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -104,7 +104,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4348104b..fba1813e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 components: clippy, rustfmt - name: Cache Rust artifacts @@ -121,7 +121,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.91.0 + toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Cache Rust artifacts diff --git a/.gitignore b/.gitignore index c6a62304..c7d66008 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ Desktop.ini # Local tests. tests/ +# Migration unit tests compile these sanitized fixtures into the binary. +!tests/ +tests/* +!tests/fixtures/ +tests/fixtures/* +!tests/fixtures/media/ +!tests/e2e/ +tests/e2e/* +!tests/e2e/non-video-media.spec.ts # Build output, dependency caches, coverage, and profiling output. target/ diff --git a/Cargo.lock b/Cargo.lock index 626d5433..49eeb275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "byteorder" @@ -909,6 +923,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1440,7 +1463,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 2.0.119", + "syn 3.0.6", "unicode-ident", "void", ] @@ -1738,6 +1761,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1769,6 +1801,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1802,6 +1840,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "ff" version = "0.13.1" @@ -1888,6 +1932,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2126,6 +2179,23 @@ dependencies = [ "weezl", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glob-match" version = "0.2.1" @@ -2155,6 +2225,15 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.19" @@ -2237,12 +2316,107 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image 0.25.10", + "kurbo", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf 0.13.1", + "rustc-hash", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2932,6 +3106,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2982,6 +3168,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3278,6 +3470,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3405,6 +3606,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus-decoder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15ae32c275dabb0cd2c863460bf349e9abc3568ba2abf0f9eb7b7d2edeeed07e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -3578,23 +3788,56 @@ dependencies = [ "base64ct", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -3602,7 +3845,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3611,13 +3867,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -3627,6 +3892,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3731,6 +4006,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.7.3" @@ -4112,6 +4396,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4174,6 +4468,12 @@ dependencies = [ "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" @@ -4315,6 +4615,12 @@ dependencies = [ "time", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4343,12 +4649,16 @@ dependencies = [ "flate2", "futures", "getrandom 0.4.3", + "gif", + "hayro", + "heic-rs", "hex", "hyper", "hyper-util", "image 0.25.10", "ipnet", "kamadak-exif", + "opus-decoder", "parking_lot", "pem-rfc7468 1.0.0", "r2d2", @@ -4371,6 +4681,7 @@ dependencies = [ "sha2 0.11.0", "sha3 0.12.0", "subtle", + "symphonia", "tar", "tempfile", "thiserror 2.0.20", @@ -4952,6 +5263,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -5140,6 +5461,191 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d015c5c0558864665894b3f4cbd95e10abb01b9c868e751c72670f326a56360e" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + [[package]] name = "syn" version = "1.0.109" @@ -6387,7 +6893,7 @@ dependencies = [ "itertools 0.15.0", "memchr", "paste", - "phf", + "phf 0.14.0", "rand 0.10.2", "rangemap", "saturating-time", @@ -6972,6 +7478,53 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 80d59473..06c65a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ name = "rustchan" version = "1.6.0" edition = "2021" -# Keep the package MSRV and cargo-msrv metadata aligned with Arti's Rust 1.91 -# requirement. +# Rust 1.99 is the supported minimum for the package and build tooling. rust-version.workspace = true license = "MIT" description = "Self-contained imageboard — single binary, zero runtime dependencies" @@ -13,13 +12,13 @@ categories = ["web-programming", "multimedia::images"] # Keep `cargo-msrv` metadata aligned with `rust-version`. [package.metadata] -msrv = "1.91" +msrv = "1.99" [workspace] resolver = "2" [workspace.package] -rust-version = "1.91" +rust-version = "1.99" [profile.release] opt-level = 3 @@ -81,6 +80,17 @@ rand_core = "0.10.1" getrandom = { version = "0.4.3", features = ["sys_rng"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "tiff", "ico"] } +# Already used by image; inspect GIF repeat/disposal metadata without retaining frames. +gif = { version = "0.14.2", default-features = false, features = ["std", "color_quant"] } +# Container inspection only for video; no video decoders are enabled. +symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "isomp4", "mkv", "ogg", "wav", "exp-video-codecs"] } +# Symphonia supplies Opus packets; this decoder avoids native libopus. +opus-decoder = { version = "0.1.1", default-features = false } +# HEVC intra still pictures only; no native library or video framework. +heic-rs = { version = "0.1.1", default-features = false, features = ["std"] } + +# Pure-Rust first-page PDF previews, with embedded standard fonts. +hayro = { version = "0.7.1", default-features = false, features = ["embed-fonts"] } # Apply JPEG Orientation tags before thumbnailing so phone photos display upright. kamadak-exif = "0.6.1" diff --git a/Dockerfile b/Dockerfile index b373058c..c494dfb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM rust:1.91.0-bookworm AS builder +FROM rust:1.99.0-bookworm AS builder WORKDIR /build # BuildKit retains compiled dependencies between builds without carrying Cargo @@ -19,10 +19,12 @@ LABEL org.opencontainers.image.title="RustChan" \ org.opencontainers.image.source="https://github.com/csd113/RustChan" \ org.opencontainers.image.licenses="MIT" -# FFmpeg and ffprobe enable RustChan's complete media pipeline. curl provides +# FFmpeg handles video and uncovered audio codecs. Rust handles other media. +# The standalone ffprobe executable is unnecessary. curl provides # a bounded readiness probe; ca-certificates support outbound TLS and Arti. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl ffmpeg && \ + rm -f /usr/bin/ffprobe && \ rm -rf /var/lib/apt/lists/* && \ groupadd --system --gid 10001 rustchan && \ useradd --system --uid 10001 --gid rustchan --home-dir /data \ diff --git a/README.md b/README.md index 7f7693a4..eb4d7267 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Choose the option that suits you: |---|---|---| | [Docker](#start-with-docker) | Running the ready-made app with its media tools included | Docker running on your computer or server | | [Download the app](#download-the-app) | Running RustChan directly, without Docker or compiling code | A supported Linux, macOS, or Windows computer | -| [Build from source](#build-from-source) | Developers who want to build the app themselves | Git and Rust 1.91 or newer | +| [Build from source](#build-from-source) | Developers who want to build the app themselves | Git and Rust 1.99 or newer | ### Start with Docker @@ -62,7 +62,7 @@ docker stop rustchan # Stop the site docker start rustchan # Start it again ``` -The image includes `ffmpeg` and `ffprobe` for video thumbnails, audio waveforms, and supported media conversion. Tor is off by default in Docker. +Images, animated GIF/WebP conversion, HEIC/HEIF, container inspection, common audio waveforms, and supported PDF previews run in Rust. The image includes `ffmpeg` for video thumbnails, WebM transcoding, and audio codecs the Rust decoders do not cover. Tor is off by default in Docker. For **Docker Compose, updates, backups, or a public website**, follow the [container guide](docs/containers.md). Public sites need HTTPS and suitable network settings; complete setup before allowing visitors. The guide also explains how to pin an image to a specific revision instead of following `latest`. @@ -89,11 +89,11 @@ chmod +x rustchan-cli On Windows, run `rustchan-cli.exe` (or `./rustchan-cli.exe` in PowerShell). Follow the terminal's first-run prompts to create your administrator and, optionally, your first board. You can also create boards in [the admin panel](http://localhost:8080/admin). Keep the app running while you use the site. -RustChan creates a `rustchan-data` folder next to the app. Keep that folder when updating. Installing `ffmpeg` and `ffprobe` adds the enhanced media features; see [SETUP.md](SETUP.md) for installation and deployment details. +RustChan creates a `rustchan-data` folder next to the app. Keep that folder when updating. Installing `ffmpeg` enables video processing and the audio compatibility fallback; see [SETUP.md](SETUP.md) for installation and deployment details. ### Build from source -With Git and Rust **1.91 or newer** installed: +With Git and Rust **1.99 or newer** installed: ```bash git clone https://github.com/csd113/RustChan.git diff --git a/SETUP.md b/SETUP.md index eb5feff4..3ac027c8 100644 --- a/SETUP.md +++ b/SETUP.md @@ -2,12 +2,13 @@ Current setup and deployment guide for Linux, macOS, and Windows. -Current version: `1.5.0`. +Current candidate version: `1.6.0`. This guide reflects the current RustChan architecture: - Tor onion hosting is built in via Arti. You do not install or manage a separate `tor` service. -- `ffmpeg` is optional, but strongly recommended if you want WebP thumbnails, WebM transcoding, video thumbnails, and audio waveforms. +- Images, GIF/WebP animation, HEIC/HEIF, container metadata, common audio waveforms, and supported PDF previews run internally in Rust. Unsupported PDF previews use the built-in SVG placeholder. +- `ffmpeg` is optional, but recommended for video thumbnails, WebM transcoding, and uncovered audio codecs. Standalone `ffprobe` and external PDF renderers are not required. - The post edit form and self-delete flow share a 60-second self-action window after posting. ## Contents @@ -33,9 +34,9 @@ This guide reflects the current RustChan architecture: RustChan is a single Rust binary. A basic install only needs: -- Rust toolchain to build it +- Rust 1.99 or newer to build it - a writable runtime data directory (next to the binary by default, or selected with `--data-dir`) -- `ffmpeg` if you want the enhanced media pipeline +- `ffmpeg` for video processing and uncovered audio codecs RustChan does not require: @@ -96,11 +97,10 @@ cargo --version When `ffmpeg` is available, RustChan can: - extract video thumbnails -- generate audio waveform thumbnails -- convert supported image thumbnails to WebP +- generate waveform thumbnails for uncovered audio codecs (for example AC-3 or Speex) - transcode MP4 uploads to WebM when VP9 and Opus are available -Without `ffmpeg`, RustChan still runs, but video and audio handling degrades gracefully. +Without `ffmpeg`, images, animation, metadata inspection, supported PDF previews, and common audio waveforms still work. Video processing and uncovered-codec waveform previews use their existing placeholders. ### Debian / Ubuntu / Raspberry Pi OS @@ -135,7 +135,6 @@ Then make sure the FFmpeg `bin` directory is on `PATH`. ```bash ffmpeg -version -ffprobe -version ``` If you want RustChan to refuse startup when `ffmpeg` is missing, set: @@ -329,7 +328,6 @@ enable_tor_support = true require_ffmpeg = false # ffmpeg_path = "/usr/local/bin/ffmpeg" -# ffprobe_path = "/usr/local/bin/ffprobe" ffmpeg_timeout_secs = 600 [tls] @@ -705,3 +703,17 @@ Back that directory up if the onion address matters. ### Optional administrator-controlled software updates For immutable native Linux version directories, a separate restricted updater service, signing trust setup and automatic backup/rollback, follow [Software Updates](docs/software-updates.md). The existing single-binary service remains deployment-managed until explicitly converted. + +### Internal media processing + +RustChan handles JPEG/PNG/BMP/TIFF, static and animated WebP, GIF conversion, +HEIC/HEIF still pictures, container metadata, and common audio waveforms in Rust. +PDF previews cover a small bounded vector/text subset; compressed streams, +embedded images/fonts and other unbudgeted features use the existing SVG preview +while the original PDF remains available. + +FFmpeg remains the video backend. It also preserves waveform support for audio +variants the internal decoders do not cover, including AC-3, Speex, unsupported +AAC profiles and Opus multistream/surround. See the +[media migration report](docs/non-video-media-migration.md) for format coverage, +resource limits and verification. diff --git a/docs/admin-redesign-coverage.md b/docs/admin-redesign-coverage.md index 22ad3cb8..695525b4 100644 --- a/docs/admin-redesign-coverage.md +++ b/docs/admin-redesign-coverage.md @@ -50,7 +50,6 @@ Every inventory leaf is listed below. All 70 leaves have working controls or a j | Master arbitrary-file upload gate | enable_any_file_uploads_feature | `#media` → `#setting-enable_any_file_uploads_feature` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Require media tools at startup | require_ffmpeg | `#media` → `#setting-require_ffmpeg` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | FFmpeg executable | ffmpeg_path | `#media` → `#setting-ffmpeg_path` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | -| FFprobe executable | ffprobe_path | `#media` → `#setting-ffprobe_path` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Generated thumbnail dimension | thumb_size | `#media` → `#setting-thumb_size` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Background job queue capacity | job_queue_capacity | `#media` → `#setting-job_queue_capacity` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | | Thumbnail/waveform cache budget | waveform_cache_max_mb | `#media` → `#setting-waveform_cache_max_mb` | Atomic root TOML; active/saved/source/next state; environment/CLI overrides; restart | 24 config-admin tests pass; 38 new JS/no-JS browser cases pass (save/restart/override/negative cases, proxy simulations, all nine themes) | diff --git a/docs/containers.md b/docs/containers.md index 77520f01..306f5518 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -1,6 +1,6 @@ # Container deployment -RustChan's container listens on HTTP port **8080**. The image includes `ffmpeg`, `ffprobe`, and CA certificates. It runs as the unprivileged `rustchan` user (UID/GID 10001). Templates and static assets are embedded in the server binary. +RustChan's container listens on HTTP port **8080**. The image includes `ffmpeg` and CA certificates. It runs as the unprivileged `rustchan` user (UID/GID 10001). Templates and static assets are embedded in the server binary. ## Pull and run diff --git a/docs/non-video-media-migration.md b/docs/non-video-media-migration.md new file mode 100644 index 00000000..220f7a0c --- /dev/null +++ b/docs/non-video-media-migration.md @@ -0,0 +1,360 @@ +# Non-video media backend migration + +Implemented backend migration, with the explicitly authorized FFmpeg compatibility fallback for uncovered audio codecs/variants. All changes remain uncommitted. Rust 1.99 is now the minimum in Cargo, CI, release builds, Docker and setup documentation. Actual video frame decoding and VP9/Opus WebM transcoding remain on FFmpeg. + +**Final verification conclusion:** suitable for review, but production readiness is not established. The final pass below records passing validation, local fixes, remaining AAC/Speex compatibility limits, and the failed Docker base-image resolution. Do not treat the initial migration results as release approval. + +## Pre-implementation audit + +| Original operation / call site | Class | Required behavior | Implemented replacement / retained backend | Semantic regression coverage | +| --- | --- | --- | --- | --- | +| `media/ffmpeg.rs::ffmpeg_image_to_webp`, used by `convert.rs` | IMAGE | JPEG, BMP, TIFF, HEIC/HEIF to metadata-free WebP; PNG retained only when smaller | Existing `image` lossless WebP encoder; `heic-rs` still decoder | Decode output, dimensions, orientation, alpha, metadata and PNG size policy | +| Same helper with GIF input | IMAGE | Animated WebP, composited frames, delays, transparency and repeat behavior | Existing `image` GIF compositor and lossless WebP encoder, bounded animation container writer | Decode every output frame and compare pixels, delays and loops | +| `ffmpeg_image_to_webp_scaled`, used by `banner.rs` | IMAGE | GIF and animated WebP banners scaled without flattening | Same animation pipeline, existing image WebP decoder | Partial frames, disposal, bounds and stored animation compatibility | +| `thumbnail.rs` image branch -> `ffmpeg_thumbnail` | IMAGE | First composited frame thumbnail, alpha, aspect ratio, no upload mutation | Existing `image` decoder/resizer/encoder plus HEIC still decoder | Thumbnail dimensions/pixels with FFmpeg absent | +| `thumbnail.rs` video branch -> `ffmpeg_thumbnail` | VIDEO | Decode compressed video frame, fit thumbnail; SVG fallback on failure | Retain FFmpeg command and libwebp capability for this video operation | Existing video thumbnail tests | +| `probe_stream_kind`, called from upload validation and worker output validation | CONTAINER PROBING | Audio/video classification; MP4/M4A, WebM/MKV, standalone audio; malformed/ambiguous handling | Symphonia ISOBMFF/Matroska/audio format parsers | Old/new stream-kind and malformed fixture comparison | +| `probe_stream_kind_with_ffmpeg` -> two zero-frame stream-map commands | CONTAINER PROBING | Establish ambiguous WebM kind without decoding, neutral download if ambiguous | Same Rust container parsers; unsupported audio containers retain bounded zero-frame FFmpeg maps | Audio WebM, video WebM and ambiguous policy tests | +| `probe_video_codec`, called by transcode preparation/output validation | CONTAINER PROBING | First video codec, skip existing VP9, verify generated VP9 | Same container parsers; actual video processing retained | MP4 H.264 and WebM VP9 before/after | +| `probe_audio_codec`, called by MIME canonicalization | CONTAINER PROBING | Primary codec determines canonical audio MIME | Audio/container parser metadata | MP1/2/3, PCM, Vorbis, FLAC, AAC, Opus and M4A | +| `workers::generate_waveform` / `waveform_prepare` -> `showwavespic` | AUDIO | PNG of thumb width × half-height, streaming decode, deterministic envelope, atomic job persistence, timeout/cancellation | Symphonia plus focused pure-Rust Opus decoder; unavailable codecs/variants use the approved FFmpeg fallback | Supported formats, silence, short/stereo audio, malformed input, job persistence and no executable | +| `thumbnail.rs`: `pdftoppm`, `mutool draw`, `qlmanage -t` | PDF | Bounded first-page WebP or existing static SVG placeholder; rendering failure does not reject valid PDF | Hayro behind a conservative input/work gate; static fallback retained | Simple render, malformed/unsupported PDF fallback | +| `workers::transcode_video` -> `build_vp9_transcode_args` | VIDEO TRANSCODING | Existing libvpx-vp9/libopus flags, color normalization, tuning, timeouts, cancellation, atomicity and queue behavior | Retain existing FFmpeg backend | Existing transcode argument, integration and job tests unchanged | +| `detect.rs`, `media/ffmpeg.rs`: executable `-version` and `-encoders` probes | Capability reporting | Accurate startup/admin status without obsolete non-video tool requirements | Retain FFmpeg/video encoder detection; standalone FFprobe and image/PDF process detection removed | Startup with nonexistent executable paths and reporting tests | + +Related audit surfaces: `media/process.rs` owns bounded subprocess output and process-group cleanup (retained for video); `utils/files/storage.rs` owns validation, orientation, size policy and atomic upload handoff; `workers/mod.rs` owns durable jobs and waveform persistence; application state, posting handlers, maintenance reports, configuration, Dockerfile, README, SETUP and browser media tests carry executable capability assumptions. SVG upload rejection and no-JS/UI hooks remain unchanged. + +## Dependencies and implementation choices + +| Direct dependency | Why selected | Enabled scope / native-library audit | +| --- | --- | --- | +| Existing `image` 0.25.10 | Reuse the existing mature codecs, resizer, orientation support and lossless WebP encoder | JPEG/PNG/GIF/WebP/BMP/TIFF/ICO; no new native image library | +| `gif` 0.14.2 (already transitive) | Read repeat metadata without retaining the whole animation | `default-features = false`; `std`, `color_quant`; pure Rust | +| [Symphonia 0.6.1](https://github.com/pdeljanov/Symphonia) | Established incremental audio decoders and content-based container parsers | Only required audio codecs and ISOBMFF/MKV/Ogg/WAV readers; experimental video **declarations** enable metadata IDs, with no video decoder registered | +| [opus-decoder 0.1.1](https://docs.rs/opus-decoder/0.1.1/opus_decoder/) | Symphonia supplies Opus packets but lacks an Opus decoder | No defaults, no native libopus, no build script; mono/stereo CELT and low-bitrate voice fixtures verified | +| [heic-rs 0.1.1](https://github.com/tbraun96/heic-rs) | Focused HEVC-intra still-picture decoder with container transforms and pixel bounds | `std` only, one decoding thread, no libheif/FFmpeg/system HEVC library or build script | +| [Hayro 0.7.1](https://github.com/LaurenzV/hayro) | First-page vector/text PDF rendering without a native PDF executable/library | No defaults; embedded standard fonts; Rust font, raster, compression and image dependencies | + +`webpkit` was evaluated; its Rust 1.96 requirement is now covered by the requested Rust 1.99 minimum. It would duplicate existing codecs. A small checked RIFF animation writer reuses `image`'s lossless VP8L encoding instead. It writes full composited frames to temporary output without retaining encoded frames for the entire animation. + +`cargo tree` and `cargo tree --edges features` were inspected. The added media dependency closures contain no FFmpeg/libwebp/libheif/native-Opus bindings or multimedia `*-sys` build dependencies. Existing SQLite/TLS/native dependencies elsewhere in the program remain. The lockfile adds 50 package versions and removes no previous package versions. `cargo deny --locked check` passed advisories, bans, licenses and sources (existing duplicate-version and `captcha` manifest-license warnings remain). + +HEIC and Opus decoders are recent focused implementations. Their selected behavior is covered by representative real/generated files, rather than a claim of universal codec conformance. In particular, the Opus crate's multistream splitter rejects a valid standard libopus 5.1 fixture; RustChan deliberately does not use that implementation. Valid mapped Opus streams use the approved compatibility backend. The pinned Opus version maps both unimplemented features and internal transform failures to public `InternalError`. That error now fails closed; only validated multistream metadata explicitly selects compatibility. + +## Before/after format coverage + +| Format/operation | Previous behavior | Current behavior / verified evidence | +| --- | --- | --- | +| JPEG, BMP, TIFF | FFmpeg WebP conversion when available; image-crate fallback previews | Rust lossless WebP; exact decoded pixel equality, dimensions, thumbnails, stripped output metadata | +| PNG, transparent PNG | Keep converted WebP only when smaller | Same size policy in Rust; alpha and decoded pixel equality preserved | +| GIF | FFmpeg animated WebP when codec/tool available | Rust animated WebP; four partial/composited frames, Keep/Previous/Background disposal, transparent pixels, 30/110/230/70 ms delays, infinite and finite loops decoded and compared | +| Static WebP | Preserve upload and generate preview | Existing upload compatibility retained; Rust preview and alpha checks | +| Animated WebP | Preserve stored animation; optional scaled animation for banners | Source animation remains intact; scaled banner output is decoded to prove multiple frames, alpha and timing | +| HEIC/HEIF | FFmpeg still-picture conversion/preview | Rust tiled HEIC and iPhone primary-image decode, alpha and container rotation; exact decoded-pixel equality after lossless WebP encoding and bounded thumbnails | +| MP4/M4A, WebM, MKV | Separate FFprobe, or zero-frame FFmpeg stream inspection | Rust content-based tracks/primary codec inspection; nineteen fixtures compared with FFprobe during final verification, plus malformed input and misleading extensions | +| MP3, FLAC, WAV/PCM, Vorbis, AAC-LC, M4A/ALAC, common Opus, audio WebM/MKV | FFmpeg waveform; placeholder without FFmpeg | Streaming Rust audio and width-sized envelope; MP3/FLAC/WAV/Vorbis/AAC/M4A/Opus/audio-WebM fixtures verified; MKV FLAC track inspection verified | +| Opus mono/stereo, voice/SILK | FFmpeg | Rust, including exact encoder-delay/end-padding handling and header gain | +| Uncovered audio: AC-3, Speex, unsupported AAC profiles, mapped Opus/multistream | FFmpeg when present, otherwise placeholder | Approved FFmpeg compatibility path, selected only for unavailable formats/codecs/variants; AC-3 M4A and standard 5.1 Opus waveform output verified | +| PDF | External Poppler/MuPDF/QuickLook preview when available, otherwise SVG | Hayro renders a small bounded uncompressed vector/text subset; broader or unsafe rendering uses the existing SVG without changing/rejecting the PDF download | +| Video frame thumbnails | FFmpeg/libwebp or SVG | Retained command unchanged; actual WebP video thumbnail browser pass | +| Video → WebM | FFmpeg libvpx-vp9/libopus | Retained encoder arguments, color normalization, CPU tuning, timeouts, cancellation, atomic DB/file lifecycle; actual browser transcode, WebM playback markup and stale-MP4 redirect pass | + +The compatibility fallback was explicitly authorized after reproducing AC-3 M4A failure in Rust and success with the original `showwavespic` command. It overrides the objective's earlier strict no-audio-FFmpeg rule. Common codecs never consult an executable to render their waveforms. + +## Equivalence, privacy and resource limits + +- Static lossless WebP pixels match the decoded source exactly; EXIF, ICC and XMP are absent from newly encoded WebP. JPEG orientation 6 remains correct through conversion and thumbnailing. The existing original-PNG size policy and original-file error fallback remain. +- GIF semantic tests decode every WebP frame and compare pixels, order, delays and repeat behavior against the original GIF compositor. A finite GIF repeat count is mapped to WebP total plays. Existing animated WebP scaling remains animated. +- HEIC fixtures include a 2048 × 1536 tiled Apple-encoded scene, 48 × 48 auxiliary alpha, 40 × 64 rotated color corners, and an actual 1512 × 850 iPhone HDR file. HEIF extension handling uses content. Pixel comparisons with FFmpeg gave mean absolute 8-bit channel differences of about 0.19 for the generated tiled scene and 4.90 for the phone primary SDR image; exact encoded bytes/colorspace rounding are not promised. HDR gain-map rendering is outside the existing primary-thumbnail behavior. Provenance and licenses are in `tests/fixtures/media/README.md`. +- The old Poppler renderer and Hayro produce the same 100 × 80 first-page geometry and red interior for the two-page vector PDF fixture. A valid compressed PDF is accepted unchanged and receives SVG. **PDF rendering coverage is deliberately narrower:** flat page trees, an object ceiling before parsing, and a source-size × viewport-pixel work ceiling precede rendering. Compressed streams, embedded images/fonts, recursion-capable resources and other unbudgeted features use placeholders because Hayro exposes no general allocation/work cancellation budget. +- Decoding is off the async executor. Existing upload limits remain; images have a 40-million-pixel budget and allocation limits. Animation processes frames incrementally, checks dimensions, limits frame count to 10,000, and uses the operator deadline. Output RIFF lengths/durations are checked, animated output is capped at 256 MiB before each frame write, and publication uses temporary files. +- HEIC source reads are bounded to the shared allocation limit; primary/alpha coded SPS dimensions are checked before plane allocation, tile counts are bounded, and the sum of coded tile areas is bounded. Codec-level work is cooperative at existing operation boundaries. +- Audio uses two streaming passes (count frames, then fill peak buckets), a 4-million-interleaved-sample packet ceiling, finite-sample checks, deterministic mean absolute channel amplitude, and existing cancellation/deadline checks. Tests cover silence, a single sample frame, opposite-phase six-channel PCM, 120-second PCM, malformed input, unavailable-codec selection and Opus trimming. No full-clip PCM buffer is retained. +- Symphonia container readers cache regular-file length and enforce cancellation, a deadline, a 64 MiB header-read ceiling, a 16 MiB packet-read ceiling, and a repeated-read budget before progressive parser allocations. Metadata tag/artwork sizes are capped. Malformed input, I/O errors, cancellation and resource failures do not select the compatibility decoder. +- Waveform persistence keeps validated paths, temporary output, database transactions, hashes, rollback cleanup and durable job recovery. Faster waveform jobs exposed a stale-SVG request race; protected-board/path checks now precede a temporary redirect to an existing completed PNG, including the race where the file disappears just before `ServeFile` opens it. + +Useful debug measurements on this Apple-silicon host: isolated HEIC decode was about 1.52 s for the tiled scene, 0.97 s for the phone photo, and 1–2 ms for the small alpha/rotation fixtures. These are debug measurements and include no release-performance claim. + +## Remaining process responsibilities + +- `media/ffmpeg.rs::ffmpeg_thumbnail`: actual video-frame decoding and WebP preview encoding. The function body was compared with HEAD and is unchanged. +- `workers::transcode_video` / `media/ffmpeg.rs::build_vp9_transcode_args`: retained video WebM transcoder. Both the argument builder and CPU profile selection were compared with HEAD and are unchanged. +- `workers::render_waveform_with_compatibility`: original bounded `showwavespic` command for uncovered audio only, sharing the worker deadline, process-group shutdown and atomic publication. +- `media/ffmpeg.rs::probe_uncovered_audio`: bounded zero-frame stream maps only after an unsupported audio container/parser result, preserving accepted codec/container variants. +- FFmpeg version/encoder detection and configuration executable validation remain for the retained backend. No standalone FFprobe or external PDF renderer process wrapper remains. Docker removes the unneeded standalone `/usr/bin/ffprobe` after installing FFmpeg. + +## Initial migration validation (superseded by final verification below) + +All checks use Rust/cargo 1.99.0. Tests include the existing video argument/lifecycle tests, newly generated media fixtures, strong image/animation pixel checks, real HEIC files, primary-codec/stream comparisons, common audio/voice decoding, multistream compatibility regression, PDF rendering/fallback, malformed inputs and atomic waveform persistence. Browser fixtures now contain real containers rather than header-only fake media; byte-exact PNG assertions allow the original smaller-WebP retention policy. + +| Command / pass | Result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| `cargo check --workspace --all-targets --all-features` | Passed | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed | +| `cargo test --workspace --all-features` | Passed: 1,266 tests in each library/application suite, 5 CLI tests, 1 UI fixture test and 1 doc test | +| `cargo tree`; `cargo tree --edges features` | Inspected | +| `cargo deny --locked check` | Passed all policies; existing duplicate-version warnings | +| `CHAN_FFMPEG_PATH=__rustchan_non_video_tools_unavailable__ CHAN_REQUIRE_FFMPEG=0 cargo test --workspace --all-features media::` | Passed: 91 media tests in each library/application suite with FFmpeg unavailable | +| `cargo test --all-features waveform_common_codecs_work_without_tools_and_uncovered_codecs_use_fallback` | Passed for common Opus with tool disabled, AC-3/Opus 5.1 with tool restored, and malformed-input rejection | +| `npx playwright test tests/e2e/upload-validation.spec.ts --project=chromium --project=chromium-nojs --workers=2` | Passed: 55 scenarios with JS, 54 with no JS and one JS-only scenario skipped | +| `npx playwright test tests/e2e/non-video-media.spec.ts --project=chromium --project=chromium-nojs --workers=2` | Passed: all eight common formats complete waveform jobs without FFmpeg in both modes | +| Final combined upload-validation and non-video-media browser run, both Chromium projects | Passed: all 4 tests with the final implementation | +| `RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg npm run test:e2e:media` | Passed: actual video WebP thumbnail, VP9/Opus WebM, redirect, audio waveform, internal PDF preview and responsive media layouts | +| `git diff --check` | Passed | + +The installed FFmpeg lacked libwebp, so a temporary source build with libwebp/libvpx/libopus was used to run the unchanged strict video browser test. It lives only under `/tmp`; no system FFmpeg installation or runtime video architecture was replaced. Docker was subsequently attempted in the final verification below. Hosted CI/release jobs were not run remotely. + +## Files changed + +- `.github/workflows/ci.yml` +- `.github/workflows/release.yml` +- `Cargo.lock` +- `Cargo.toml` +- `Dockerfile` +- `README.md` +- `SETUP.md` +- `docs/admin-redesign-coverage.md` +- `docs/containers.md` +- `docs/non-video-media-migration.md` +- `docs/strict-lint-exceptions.tsv` +- `src/banner.rs` +- `src/config.rs` +- `src/config/admin.rs` +- `src/config/admin/runtime.rs` +- `src/config/template.rs` +- `src/detect.rs` +- `src/handlers/admin/mod.rs` +- `src/handlers/board/access_preferences.rs` +- `src/handlers/board/create_thread.rs` +- `src/handlers/board/media.rs` +- `src/handlers/mod.rs` +- `src/handlers/posting.rs` +- `src/handlers/setup.rs` +- `src/handlers/thread.rs` +- `src/media/audio.rs` +- `src/media/convert.rs` +- `src/media/ffmpeg.rs` +- `src/media/heif.rs` +- `src/media/images.rs` +- `src/media/images/tests.rs` +- `src/media/mod.rs` +- `src/media/pdf.rs` +- `src/media/probe.rs` +- `src/media/thumbnail.rs` +- `src/middleware/state.rs` +- `src/pending_fs.rs` +- `src/server/server.rs` +- `src/templates/admin.rs` +- `src/templates/admin/maintenance.rs` +- `src/templates/admin/network.rs` +- `src/templates/admin/site_health.rs` +- `src/test_fixtures.rs` +- `src/utils/files/storage.rs` +- `src/workers/mod.rs` +- `tests/cli_runtime.rs` +- `tests/e2e/README.md` +- `tests/e2e/helpers.ts` +- `tests/e2e/media-toolchain-check.mjs` +- `tests/e2e/media-toolchain.spec.ts` +- `tests/e2e/non-video-media.spec.ts` +- `tests/e2e/upload-regressions.spec.ts` +- `tests/e2e/upload-validation.spec.ts` +- `tests/fixtures/media/README.md` +- `tests/fixtures/media/ac3.m4a` +- `tests/fixtures/media/alpha.heic` +- `tests/fixtures/media/audio.mkv` +- `tests/fixtures/media/audio.webm` +- `tests/fixtures/media/compressed.pdf` +- `tests/fixtures/media/irot90.heic` +- `tests/fixtures/media/phone-hdr-LICENSE` +- `tests/fixtures/media/phone-hdr.heic` +- `tests/fixtures/media/photo-LICENSE-APACHE` +- `tests/fixtures/media/photo-LICENSE-MIT` +- `tests/fixtures/media/photo.heic` +- `tests/fixtures/media/simple.pdf` +- `tests/fixtures/media/speech-mode.opus` +- `tests/fixtures/media/surround.opus` +- `tests/fixtures/media/tone.aac` +- `tests/fixtures/media/tone.flac` +- `tests/fixtures/media/tone.m4a` +- `tests/fixtures/media/tone.mp3` +- `tests/fixtures/media/tone.ogg` +- `tests/fixtures/media/tone.opus` +- `tests/fixtures/media/tone.wav` +- `tests/fixtures/media/video.mkv` +- `tests/fixtures/media/video.mp4` +- `tests/fixtures/media/video.webm` + +## Final verification and polish pass + +The current candidate, including changed and untracked source, configuration, templates, documentation, tests and fixtures, was reviewed against the pre-migration HEAD. Existing user changes were preserved. No commit, branch, push, tag, release, backup deletion or known-good replacement was performed. No application dependency was added during this pass. + +### Issues found and local fixes + +- The broad `tests/` ignore hid new compile-time media fixtures and the new browser specification. Narrow exceptions now expose those inputs to Git while generated browser output remains ignored. These untracked files must accompany the candidate when it is eventually committed. +- Unsupported-codec routing was too broad: malformed container errors, resource failures and Opus internal decoder errors could select FFmpeg. Routing now recognizes explicit audited unsupported variants; malformed input, I/O, cancellation, deadlines and limits fail closed. AAC configuration checks distinguish valid unsupported profiles and explicit/implicit SBR metadata from malformed configurations. +- Standard Ogg Speex could disappear inside Symphonia's unsupported Ogg mapping. A bounded page validator now checks identification metadata, framing, CRC, sequence, continuation and completion before selecting compatibility. Real Speex, HE-AAC M4A, AC-3 and mapped surround Opus exercise the worker fallback. +- Container parsing could allocate from hostile packet lengths before the later sample limit. File reads now enforce cancellation/deadlines and header/packet read ceilings before progressive parser allocation. ADTS validation scans fixed headers and seeks over payload instead of buffering the file; reserved frequency indices are rejected. +- PDF page-tree recursion could occur before the existing post-parse object limit. Preflight now rejects recursive/indirect page trees, duplicate objects, revisions and unbudgeted stream/resource structures before Hayro parsing. A checked source-byte × viewport-pixel work ceiling also precedes rendering. Unsupported PDFs keep their original download and deterministic SVG. +- Animated WebP checked the RIFF limit only after encoding the entire animation. The writer now checks a 256 MiB output budget before each frame is published to its temporary output; failures preserve the original upload. +- Completed MKV transcodes could leave stale original URLs returning 404. Validated MP4/MKV URLs now redirect to the completed WebM, including the file-open race. Protected-board and path/symlink checks still precede redirect handling. +- A global test-only PDF renderer override could leak between concurrent tests. The override is now thread-local and guarded; production behavior is unchanged. +- Browser fixtures/assertions assumed obsolete MIME choices, optional local audio encoders and a no-JS mouse gesture that stalled Chromium. Tests now follow the stored media URL, reuse real bundled audio fixtures, assert exact intentional negative-upload diagnostics, and check no-JS resize behavior through CSS. Common-audio browser coverage adds ALAC and SILK/voice Opus. +- Added representative large transparent image, truncated image, malformed container, bounded-read, PDF vector/text and resource regressions. Refreshed the narrow lint-exception ledger, fixture provenance, setup version and stale FFprobe wording. + +### Final validation + +All local Rust checks used Rust/cargo 1.99.0. The following final checks passed after the production changes: + +| Command / pass | Final result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| `cargo check --workspace --all-targets --all-features` | Passed | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed | +| `cargo test --workspace --all-features` | 1,275 library + 1,275 application unit tests, 5 CLI integration tests, 1 UI fixture integration test and 1 doc test: **2,557 passed**, zero failures | +| `cargo tree`; `cargo tree --edges features` | Inspected; no new native multimedia requirement in the added dependency closures | +| `cargo deny --locked check` | Advisories, bans, licenses and sources passed; existing duplicate-version and `captcha` manifest-license warnings remain | +| `CHAN_FFMPEG_PATH=__rustchan_non_video_tools_unavailable__ CHAN_REQUIRE_FFMPEG=0 cargo test --workspace --all-features media::` | **100 + 100 media tests passed** with FFmpeg unavailable | +| Chromium + Chromium no-JS: upload validation, non-video media, media and phase-3 runtime | **14 passed, 4 intentional skips**; upload matrix covers 55 JS scenarios and 54 no-JS scenarios, with one JS-only scenario skipped; ten common audio formats complete jobs without FFmpeg | +| Chromium + Chromium no-JS: real media toolchain, auto-compression, opt-in upload regressions and phase-3 runtime | **14 passed, 8 intentional project/JS-only skips**; actual WebP video thumbnails, VP9/Opus WebM, redirects, playback markup and oversized-upload cleanup verified | +| Chromium + Chromium no-JS: affected PDF/media toolchain rerun after the final PDF work ceiling | **8 passed**, zero skips | +| `git diff --check` | Passed, including the final report edits | + +The combined browser command was: + +```sh +RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-browser-verified npx playwright test tests/e2e/upload-validation.spec.ts tests/e2e/non-video-media.spec.ts tests/e2e/media.spec.ts tests/e2e/phase3-media-runtime.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +The real-video command was: + +```sh +RUSTCHAN_UPLOAD_REGRESSION_E2E=1 RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1 RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-real-media-verified npx playwright test tests/e2e/media-toolchain.spec.ts tests/e2e/auto-compress.spec.ts tests/e2e/upload-regressions.spec.ts tests/e2e/phase3-media-runtime.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +The final affected browser rerun was: + +```sh +RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1 RUSTCHAN_E2E_FFMPEG_PATH=/tmp/rustchan-video-validation/ffmpeg-9.0.2/ffmpeg RUSTCHAN_AUDIT_OUTPUT=output/playwright/final-verification-pdf-budget npx playwright test tests/e2e/media-toolchain.spec.ts tests/e2e/media.spec.ts --project=chromium --project=chromium-nojs --workers=2 +``` + +Final logs are under `/tmp/rustchan-final-verification/`; browser results are in the ignored `output/playwright/final-verification-*` directories. The temporary FFmpeg build includes libwebp/libvpx/libopus and did not replace the installed executable. A temporary Speex fixture encoder was built from the [official Speex release source](https://www.speex.org/downloads/); it is not an application dependency. + +The retained `ffmpeg_thumbnail`, `build_vp9_transcode_args`, `vp9_encoding_profile`, `wait_for_ffmpeg_output`, `waveform_finalise` and `transcode_video_finalise` function bodies were compared with HEAD and remain unchanged. Nineteen real media fixtures were compared with FFprobe as an oracle, including multi-stream MP4, audio-only containers, M4A, WebM and MKV. FFprobe is not required for normal Rust probing. + +### Unresolved compatibility and release limits + +1. **In-band HE-AAC ADTS is a confirmed gap.** Remuxing the HE-AAC M4A fixture to ADTS preserves a valid 48 kHz, two-channel HE-AAC stream according to FFprobe. Symphonia 0.6.1 instead declares and decodes the 24 kHz AAC-LC core successfully, exposes no SBR profile/configuration through its public metadata, and does not return an unsupported error. The compatibility fallback is therefore not selected. Correct SBR reconstruction and waveform equivalence are not established. Header-signaled HE-AAC M4A now falls back correctly, but that does not repair this packet-signaled case. Accurate detection needs decoder support; a payload signature heuristic or routing all common AAC through FFmpeg would violate the intended routing policy. +2. **Speex coverage is deliberately limited to a validated single logical Ogg stream.** Chained/multiplexed Speex is rejected by the new validator. Other unvalidated AAC configurations and payload-level unsupported features also fail closed. These limits prevent a claim that every previously FFmpeg-supported upload remains equivalent. +3. **Docker build/runtime is unresolved.** Docker Desktop and the daemon were available. The actual `docker build --progress=plain --tag rustchan-final-verification:local .` attempt failed while resolving `docker.io/library/rust:1.99.0-bookworm`: `not found`. No builder step ran. The Linux production binary, runtime startup/health, non-root execution and in-container retained FFmpeg behavior remain unverified. An older Rust base image was not substituted and no image was pushed. +4. Image/HEIC codec calls remain cooperative at bounded operation boundaries; the wrapper cannot forcibly interrupt one library decode. Representative behavior and malformed-input/resource regressions passed, but this is not universal codec conformance or a formal bound on every decoder instruction. Hosted CI/release jobs were not run remotely. + +### Release gates + +| # | Gate | Assessment | +| --- | --- | --- | +| 1 | Common non-video media does not require FFmpeg | **Satisfied for the representative supported formats**, including ALAC and SILK/voice Opus | +| 2 | Video still works through FFmpeg | **Satisfied** by real thumbnails/transcoding/browser playback and unchanged argument builders | +| 3 | Approved uncommon-audio fallback works | **Partially satisfied**: AC-3, standard Speex, HE-AAC M4A and surround Opus pass; in-band HE-AAC ADTS remains uncovered | +| 4 | Malformed errors cannot incorrectly select compatibility fallback | **Satisfied for audited routing and regression cases**; explicit unsupported classification is separated from malformed/I/O/resource/cancellation errors | +| 5 | PDF fallback behaves intentionally | **Satisfied**: supported vector/text render; complex or unbudgeted inputs retain the original and receive SVG | +| 6 | No meaningful supported upload behavior was lost | **Not satisfied**: the AAC gap and Speex/other AAC limits prevent certification | +| 7 | Resource/security limits remain enforced | **Satisfied for audited wrappers and tested cases**, with cooperative library-decode limits disclosed above | +| 8 | Docker validates locally, or inability is explicitly reported | **Failure explicitly reported; build/runtime release validation remains unresolved** because the required base tag is unavailable | +| 9 | Rust 1.99 is consistent | **Satisfied** in active Cargo, CI, release, Docker and user/developer setup requirements; historical references are intentional | +| 10 | No unexplained working-tree changes | **Satisfied**: candidate changes reviewed and this pass's additions/fixes listed below | + +The working tree is safe to inspect and review. It is **not certified for a production-ready migration commit or release** until the compatibility gaps and Docker validation are resolved. All changes remain uncommitted. + +### Files changed during this final pass only + +The broader candidate file list above includes earlier migration changes. This pass touched these 24 files: + +- `.gitignore` +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `SETUP.md` +- `docs/non-video-media-migration.md` +- `docs/strict-lint-exceptions.tsv` +- `src/handlers/board/media.rs` +- `src/media/audio.rs` +- `src/media/images.rs` +- `src/media/images/tests.rs` +- `src/media/pdf.rs` +- `src/media/probe.rs` +- `src/media/probe/speex.rs` (new in this pass) +- `src/media/thumbnail.rs` +- `src/utils/files/storage.rs` +- `src/workers/mod.rs` +- `tests/e2e/non-video-media.spec.ts` +- `tests/e2e/phase3-media-runtime.spec.ts` +- `tests/e2e/upload-regressions.spec.ts` +- `tests/fixtures/media/README.md` +- `tests/fixtures/media/tone-alac.m4a` (new in this pass) +- `tests/fixtures/media/multiple-streams.mp4` (new in this pass) +- `tests/fixtures/media/he-aac.m4a` (new in this pass) +- `tests/fixtures/media/tone.spx` (new in this pass) +- `tests/fixtures/media/text.pdf` (new in this pass) + +### Final `git status --short` + +```text + M .github/ISSUE_TEMPLATE/bug_report.yml + M .github/workflows/ci.yml + M .github/workflows/release.yml + M .gitignore + M Cargo.lock + M Cargo.toml + M Dockerfile + M README.md + M SETUP.md + M docs/admin-redesign-coverage.md + M docs/containers.md + M docs/strict-lint-exceptions.tsv + M src/banner.rs + M src/config.rs + M src/config/admin.rs + M src/config/admin/runtime.rs + M src/config/template.rs + M src/detect.rs + M src/handlers/admin/mod.rs + M src/handlers/board/access_preferences.rs + M src/handlers/board/create_thread.rs + M src/handlers/board/media.rs + M src/handlers/mod.rs + M src/handlers/posting.rs + M src/handlers/setup.rs + M src/handlers/thread.rs + M src/media/convert.rs + M src/media/ffmpeg.rs + M src/media/mod.rs + M src/media/thumbnail.rs + M src/middleware/state.rs + M src/pending_fs.rs + M src/server/server.rs + M src/templates/admin.rs + M src/templates/admin/maintenance.rs + M src/templates/admin/network.rs + M src/templates/admin/site_health.rs + M src/test_fixtures.rs + M src/utils/files/storage.rs + M src/workers/mod.rs + M tests/cli_runtime.rs + M tests/e2e/README.md + M tests/e2e/helpers.ts + M tests/e2e/media-toolchain-check.mjs + M tests/e2e/media-toolchain.spec.ts + M tests/e2e/phase3-media-runtime.spec.ts + M tests/e2e/upload-regressions.spec.ts + M tests/e2e/upload-validation.spec.ts +?? docs/non-video-media-migration.md +?? src/media/audio.rs +?? src/media/heif.rs +?? src/media/images.rs +?? src/media/images/ +?? src/media/pdf.rs +?? src/media/probe.rs +?? src/media/probe/ +?? tests/e2e/non-video-media.spec.ts +?? tests/fixtures/ +``` diff --git a/docs/strict-lint-exceptions.tsv b/docs/strict-lint-exceptions.tsv index 786f70ae..37b0f877 100644 --- a/docs/strict-lint-exceptions.tsv +++ b/docs/strict-lint-exceptions.tsv @@ -1,6 +1,6 @@ path line attribute lints reason scope src/banner.rs 1125 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn rejects_banner_gif_frame_bomb -src/banner.rs 1148 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_gif_banner_falls_back_to_gif_without_ffmpeg +src/banner.rs 1148 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_gif_banner_converts_to_animated_webp_without_ffmpeg src/banner.rs 1196 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn deleting_banner_asset_removes_webp_and_gif_siblings src/banner.rs 1235 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn animated_webp_banner_is_preserved_without_static_decode src/banner.rs 1264 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn rejects_oversized_banner_dimensions @@ -149,7 +149,7 @@ src/handlers/admin/settings/themes.rs 252 expect clippy::too_many_lines builder src/handlers/board/access_preferences.rs 959 expect clippy::too_many_lines rate limiting, credential checks, signed access grants, and redirect validation form one request fn unlock_board_access src/handlers/board/catalog.rs 26 expect clippy::too_many_lines access control, catalog loading, unread-state calculation, and rendering form one request fn catalog src/handlers/board/create_thread.rs 15 expect clippy::too_many_lines access checks, multipart validation, thread creation, and response cookies form one request fn create_thread -src/handlers/board/media.rs 91 expect clippy::too_many_lines path validation, access policy, legacy redirect handling, and file response form one request fn serve_board_media +src/handlers/board/media.rs 103 expect clippy::too_many_lines path validation, access policy, legacy redirect handling, and file response form one request fn serve_board_media src/handlers/board/pages.rs 37 expect clippy::too_many_lines home-page data loading, activity markers, banner selection, and rendering form one request fn index src/handlers/board/pages.rs 226 expect clippy::too_many_lines board access, pagination, activity markers, banner selection, and rendering form one request fn board_index src/handlers/board/tests.rs 432 expect clippy::too_many_lines the end-to-end test keeps its fixture setup and ordered assertions in one scenario fn post_password_board_write_actions_require_unlock @@ -189,11 +189,8 @@ src/media/prune.rs 524 expect clippy::panic_in_result_fn test assertions intenti src/media/prune.rs 595 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_removes_secondary_audio_original_with_combo_post src/media/prune.rs 657 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_media_paths src/media/prune.rs 711 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_parent_components -src/media/thumbnail.rs 383 expect clippy::cognitive_complexity the renderer fallback and fail-closed validation sequence is intentionally linear fn pdf_first_page_thumbnail -src/media/thumbnail.rs 387 expect clippy::too_many_lines PDF rendering keeps subprocess validation, fallback, and cleanup in one auditable sequence fn pdf_first_page_thumbnail -src/media/thumbnail.rs 830 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg -src/media/thumbnail.rs 845 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg -src/media/thumbnail.rs 868 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn mutool_command_caps_render_dimensions +src/media/thumbnail.rs 496 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg +src/media/thumbnail.rs 511 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg src/middleware/state.rs 342 expect clippy::struct_excessive_bools independent runtime capability flags are shared across handlers and workers struct AppState src/middleware/state.rs 435 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_assign_monotonic_job_ids_and_preserve_them_in_terminal_states src/middleware/state.rs 527 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_finished_updates_only_matching_running_job @@ -326,11 +323,10 @@ src/tls/self_signed.rs 283 expect clippy::panic_in_result_fn assertions intentio src/tls/self_signed.rs 342 expect clippy::panic_in_result_fn assertions intentionally report violations of missing-key recovery fn missing_key_triggers_regeneration_on_next_startup src/utils/crypto.rs 70 expect clippy::exit fail-closed randomness failures must terminate before issuing weak tokens fn fatal_randomness_error src/utils/crypto.rs 212 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn hash_and_verify_password -src/utils/files/storage.rs 34 expect clippy::struct_excessive_bools the booleans are independent upload-policy and media-capability flags struct SaveUploadOptions -src/utils/files/storage.rs 135 expect clippy::too_many_arguments the arguments mirror independent upload metadata and board limits at the handler boundary fn save_audio_with_image_thumb_from_path -src/utils/files/storage.rs 246 expect clippy::as_conversions,clippy::cast_precision_loss human-readable size display intentionally rounds integral byte counts to one decimal place fn format_file_size -src/utils/files/storage.rs 1499 expect clippy::print_stderr test skip diagnostics must remain visible when optional ffmpeg fixtures are unavailable fn valid_audio_uploads_accept_common_formats_with_ffprobe -src/utils/files/storage.rs 1554 expect clippy::print_stderr test skip diagnostics must remain visible when optional ffmpeg fixtures are unavailable fn valid_mkv_uploads_save_as_video_with_probe +src/utils/files/storage.rs 121 expect clippy::redundant_pub_crate this opaque validated-upload handoff is shared across sibling crate modules fn save_validated_upload_from_path +src/utils/files/storage.rs 247 expect clippy::as_conversions,clippy::cast_precision_loss human-readable size display intentionally rounds integral byte counts to one decimal place fn format_file_size +src/utils/files/storage.rs 349 expect clippy::redundant_pub_crate this opaque validation token crosses the private storage-module boundary struct ValidatedUpload +src/utils/files/storage.rs 385 expect clippy::redundant_pub_crate the posting handler must obtain the opaque token without exposing it publicly fn validate_upload_for_storage src/utils/sanitize.rs 29 expect clippy::expect_used the reply-reference regex is a source literal covered by sanitizer tests item RE_REPLY src/utils/sanitize.rs 37 expect clippy::expect_used the cross-board-link regex is a source literal covered by sanitizer tests item RE_CROSSLINK src/utils/sanitize.rs 46 expect clippy::expect_used the URL regex is a source literal covered by sanitizer tests item RE_URL @@ -338,31 +334,46 @@ src/utils/sanitize.rs 55 expect clippy::expect_used the bold-markup regex is a s src/utils/sanitize.rs 63 expect clippy::expect_used the italic-markup regex is a source literal covered by sanitizer tests item RE_ITALIC src/utils/sanitize.rs 71 expect clippy::expect_used the spoiler-markup regex is a source literal covered by sanitizer tests item RE_SPOILER src/utils/sanitize.rs 80 expect clippy::expect_used the dice-expression regex is a source literal covered by sanitizer tests item RE_DICE -src/workers/mod.rs 699 expect clippy::cognitive_complexity,clippy::too_many_lines claim, execution, completion persistence, retry, and shutdown form one worker state machine fn worker_loop -src/workers/mod.rs 864 expect clippy::cognitive_complexity the dispatcher mirrors the closed set of background job variants fn handle_job -src/workers/mod.rs 996 expect clippy::cognitive_complexity transcode process control and fail-closed cleanup share one lifecycle fn transcode_video -src/workers/mod.rs 1111 expect clippy::cognitive_complexity source validation and codec-specific preparation share one preflight boundary fn transcode_video_prepare -src/workers/mod.rs 1660 expect clippy::cognitive_complexity archive, prune, and filesystem finalization remain one consistency operation fn prune_threads -src/workers/mod.rs 1850 expect clippy::expect_used test database queries stop locally with fixture-specific failure context fn file_hash_count -src/workers/mod.rs 1875 expect clippy::expect_used constructing the constrained test pool is a prerequisite for completion-retry tests fn single_connection_test_pool -src/workers/mod.rs 1905 expect clippy::expect_used claiming the seeded job is a prerequisite for each queue-state assertion fn claim_job -src/workers/mod.rs 1918 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn retryable_failure_restores_queue_pending_count -src/workers/mod.rs 1948 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn permanent_failure_leaves_queue_pending_count_at_zero -src/workers/mod.rs 1984 expect clippy::expect_used the async retry test requires every timed and database fixture step to succeed fn completion_retries_the_same_job_after_pool_exhaustion -src/workers/mod.rs 2037 expect clippy::expect_used the shutdown recovery test requires every timed and database fixture step to succeed fn completion_stops_retrying_after_shutdown_grace_and_startup_can_recover -src/workers/mod.rs 2077 expect clippy::expect_used the completion recovery fixture must resolve within its explicit test timeout fn already_resolved_completion_error_does_not_wedge_worker -src/workers/mod.rs 2113 expect clippy::too_many_lines the regression keeps job seeding, terminal persistence, and replay checks in one end-to-end scenario fn non_transient_done_completion_is_terminal_and_never_replayed -src/workers/mod.rs 2231 expect clippy::expect_used the injected retry-state fixture requires each database and timeout step to succeed fn non_transient_failed_completion_preserves_bounded_attempt_count -src/workers/mod.rs 2349 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_transcode_persist_removes_unattached_webm_and_writes_no_hash -src/workers/mod.rs 2385 expect clippy::expect_used temporary-file fixture setup must succeed before transcode output assertions fn transcode_finalise_skips_larger_output_and_cleans_temp_file -src/workers/mod.rs 2429 expect clippy::expect_used temporary upload fixture setup must succeed before path validation assertions fn transcode_source_validation_rejects_escape_paths -src/workers/mod.rs 2448 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumbs_dir -src/workers/mod.rs 2475 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_board_dir -src/workers/mod.rs 2502 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumb_file -src/workers/mod.rs 2526 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_waveform_finalise_removes_unattached_png_and_writes_no_hash +src/workers/mod.rs 964 expect clippy::cognitive_complexity,clippy::too_many_lines claim, execution, completion persistence, retry, and shutdown form one worker state machine fn worker_loop +src/workers/mod.rs 1130 expect clippy::cognitive_complexity,clippy::too_many_arguments the dispatcher mirrors the closed set of background job variants fn handle_job +src/workers/mod.rs 1269 expect clippy::too_many_arguments transcode process control and fail-closed cleanup share one lifecycle fn transcode_video +src/workers/mod.rs 1380 expect clippy::cognitive_complexity source validation and codec-specific preparation share one preflight boundary fn transcode_video_prepare +src/workers/mod.rs 1551 expect clippy::too_many_arguments the finalisation boundary carries the durable job and all validated filesystem identities fn transcode_video_finalise +src/workers/mod.rs 1593 expect clippy::too_many_arguments output installation and atomic DB completion require the full validated transcode identity fn persist_transcoded_webm +src/workers/mod.rs 1934 expect clippy::too_many_arguments the finalisation boundary explicitly carries all validated paths and DB identity fn waveform_finalise +src/workers/mod.rs 2115 expect clippy::cognitive_complexity archive, prune, and filesystem finalization remain one consistency operation fn prune_threads +src/workers/mod.rs 2561 expect clippy::cognitive_complexity,clippy::too_many_lines the bounded legacy-state matrix is intentionally explicit fn reconcile_media_job_states +src/workers/mod.rs 3050 expect clippy::expect_used test database queries stop locally with fixture-specific failure context fn file_hash_count +src/workers/mod.rs 3227 expect clippy::expect_used fixture setup failures should stop this queue durability test fn required_thread_prune_bypasses_capacity_and_survives_queue_recreation +src/workers/mod.rs 3690 expect clippy::expect_used fixture setup failures should stop this coalescing test fn board_prune_coalesces_and_running_work_leaves_one_dirty_evaluation +src/workers/mod.rs 3891 expect clippy::too_many_lines the legacy reconciliation matrix remains explicit in one fixed-point test fn media_reconciliation_repairs_legacy_matrix_and_reaches_fixed_point +src/workers/mod.rs 4056 expect clippy::expect_used constructing the constrained test pool is a prerequisite for completion-retry tests fn single_connection_test_pool +src/workers/mod.rs 4086 expect clippy::expect_used claiming the seeded job is a prerequisite for each queue-state assertion fn claim_job +src/workers/mod.rs 4099 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn retryable_failure_restores_queue_pending_count +src/workers/mod.rs 4129 expect clippy::expect_used test fixture failures stop the individual queue accounting test with local context fn permanent_failure_leaves_queue_pending_count_at_zero +src/workers/mod.rs 4165 expect clippy::expect_used the async retry test requires every timed and database fixture step to succeed fn completion_retries_the_same_job_after_pool_exhaustion +src/workers/mod.rs 4218 expect clippy::expect_used the shutdown recovery test requires every timed and database fixture step to succeed fn completion_stops_retrying_after_shutdown_grace_and_startup_can_recover +src/workers/mod.rs 4258 expect clippy::expect_used the completion recovery fixture must resolve within its explicit test timeout fn already_resolved_completion_error_does_not_wedge_worker +src/workers/mod.rs 4294 expect clippy::too_many_lines the regression keeps job seeding, terminal persistence, and replay checks in one end-to-end scenario fn non_transient_done_completion_is_terminal_and_never_replayed +src/workers/mod.rs 4412 expect clippy::expect_used the injected retry-state fixture requires each database and timeout step to succeed fn non_transient_failed_completion_preserves_bounded_attempt_count +src/workers/mod.rs 4532 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_transcode_persist_removes_unattached_webm_and_writes_no_hash +src/workers/mod.rs 4569 expect clippy::expect_used temporary-file fixture setup must succeed before transcode output assertions fn transcode_finalise_skips_larger_output_and_cleans_temp_file +src/workers/mod.rs 4614 expect clippy::expect_used temporary upload fixture setup must succeed before path validation assertions fn transcode_source_validation_rejects_escape_paths +src/workers/mod.rs 4633 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumbs_dir +src/workers/mod.rs 4663 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_board_dir +src/workers/mod.rs 4693 expect clippy::expect_used Unix symlink fixture setup must succeed before containment assertions fn thumb_cache_eviction_skips_symlinked_thumb_file +src/workers/mod.rs 4721 expect clippy::expect_used fixture setup must succeed before eviction assertions fn thumb_cache_evicts_only_unreferenced_files_across_all_media_states +src/workers/mod.rs 4805 expect clippy::expect_used temporary-file and database fixture setup must succeed before cleanup assertions fn stale_waveform_finalise_removes_unattached_png_and_writes_no_hash tests/cli_runtime.rs 1 expect unused_crate_dependencies Cargo exposes all package dependencies to this black-box integration target, which intentionally interacts only with the compiled CLI crate tests/cli_runtime.rs 263 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn help_and_version_do_not_create_runtime_state tests/cli_runtime.rs 304 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn explicit_data_dir_owns_all_initialized_runtime_state tests/cli_runtime.rs 356 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn relative_data_dir_is_rejected_before_filesystem_mutation tests/cli_runtime.rs 396 expect clippy::too_many_lines one subprocess lifecycle verifies the complete direct-HTTPS topology and cookie contract fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies tests/cli_runtime.rs 400 expect clippy::panic_in_result_fn assertions intentionally report violated TLS topology and cookie invariants fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies +src/media/audio.rs 440 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions clamped normalized amplitudes map to a bounded pixel radius fn render_peaks +src/media/heif.rs 12 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode +src/media/images.rs 21 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn validate_dimensions +src/media/images.rs 35 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode_still +src/media/images.rs 74 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn image_to_webp +src/media/pdf.rs 30 expect clippy::redundant_pub_crate PDF rendering is an internal media helper fn render +src/media/pdf.rs 34 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions finite positive viewport dimensions are clamped to a checked u16 bound before rounding fn render diff --git a/src/banner.rs b/src/banner.rs index 83286eb5..0e318ffd 100644 --- a/src/banner.rs +++ b/src/banner.rs @@ -682,44 +682,17 @@ fn write_animated_gif_banner_asset_scaled( target_path: &Path, original_width: u32, original_height: u32, -) -> Result<(u32, u32)> { - write_animated_gif_banner_asset_scaled_with_caps( - bytes, - target_path, - original_width, - original_height, - crate::media::ffmpeg::detect_ffmpeg(), - crate::media::ffmpeg::check_webp_encoder(), - ) -} - -/// Store an animated GIF as scaled WebP or an unmodified GIF fallback. -fn write_animated_gif_banner_asset_scaled_with_caps( - bytes: &[u8], - target_path: &Path, - original_width: u32, - original_height: u32, - ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> Result<(u32, u32)> { let (max_width, max_height) = maybe_shrink_dimensions(original_width, original_height); - if !ffmpeg_available || !ffmpeg_webp_available { - write_gif_banner_fallback(bytes, target_path)?; - return Ok((original_width, original_height)); - } let input_path = animated_gif_temp_path(target_path); std::fs::write(&input_path, bytes) .with_context(|| format!("write {}", input_path.display()))?; let webp_path = banner_webp_path(target_path); - let conversion = crate::media::ffmpeg::ffmpeg_image_to_webp_scaled( - &input_path, - &webp_path, - max_width, - max_height, - ) - .with_context(|| format!("convert animated gif banner {}", input_path.display())); + let conversion = + crate::media::images::image_to_webp(&input_path, &webp_path, Some((max_width, max_height))) + .with_context(|| format!("convert animated gif banner {}", input_path.display())); drop(std::fs::remove_file(&input_path)); match conversion { Ok(()) => { @@ -737,7 +710,7 @@ fn write_animated_gif_banner_asset_scaled_with_caps( } } -/// Store an animated WebP, scaling it when the external encoder is available. +/// Store an animated WebP, scaling its composited frames in Rust. fn write_animated_webp_banner_asset( bytes: &[u8], target_path: &Path, @@ -746,18 +719,14 @@ fn write_animated_webp_banner_asset( ) -> Result<(u32, u32)> { let (max_width, max_height) = maybe_shrink_dimensions(original_width, original_height); let should_scale = (max_width, max_height) != (original_width, original_height); - if should_scale - && crate::media::ffmpeg::detect_ffmpeg() - && crate::media::ffmpeg::check_webp_encoder() - { + if should_scale { let input_path = animated_webp_temp_path(target_path); std::fs::write(&input_path, bytes) .with_context(|| format!("write {}", input_path.display()))?; - let conversion = crate::media::ffmpeg::ffmpeg_image_to_webp_scaled( + let conversion = crate::media::images::image_to_webp( &input_path, target_path, - max_width, - max_height, + Some((max_width, max_height)), ) .with_context(|| format!("scale animated webp banner {}", input_path.display())); drop(std::fs::remove_file(&input_path)); @@ -967,16 +936,16 @@ fn is_animated_webp(bytes: &[u8]) -> bool { #[cfg(test)] mod tests { use super::{ - banner_admin_anchor, banner_asset_content_type, banner_gif_fallback_path, - banner_open_section, banner_storage_path, banner_target_draft, canonicalize_banner_bytes, - choose_active_banner, is_animated_webp, normalize_external_url, normalize_internal_path, - parse_banner_target, resolve_banner_href, safe_return_to, - validate_banner_restore_entry_name, validate_banner_storage_key, - write_animated_gif_banner_asset_scaled_with_caps, write_animated_webp_banner_asset, - DISPLAY_HEIGHT, DISPLAY_WIDTH, MAX_ANIMATED_GIF_FRAMES, + banner_admin_anchor, banner_gif_fallback_path, banner_open_section, banner_storage_path, + banner_target_draft, canonicalize_banner_bytes, choose_active_banner, is_animated_webp, + normalize_external_url, normalize_internal_path, parse_banner_target, resolve_banner_href, + safe_return_to, validate_banner_restore_entry_name, validate_banner_storage_key, + write_animated_gif_banner_asset_scaled, write_animated_webp_banner_asset, DISPLAY_HEIGHT, + DISPLAY_WIDTH, MAX_ANIMATED_GIF_FRAMES, }; use crate::models::{BannerAsset, BannerScope, BannerTargetType}; use anyhow::Result; + use image::AnimationDecoder as _; use image::{codecs::gif::GifEncoder, Delay, Frame, ImageBuffer, ImageFormat, Rgba}; use std::io::Cursor; @@ -1036,13 +1005,13 @@ mod tests { fn banner_target_draft_only_populates_selected_field() { let board = banner_target_draft(BannerTargetType::InternalBoard, "tech"); assert_eq!(board.board_value, "tech"); - assert!(board.thread_value.is_empty()); - assert!(board.external_url.is_empty()); + assert_eq!(board.thread_value.len(), 0); + assert_eq!(board.external_url, ""); let thread = banner_target_draft(BannerTargetType::InternalPath, "/tech/thread/42"); - assert!(thread.board_value.is_empty()); + assert_eq!(thread.board_value.len(), 0); assert_eq!(thread.thread_value, "/tech/thread/42"); - assert!(thread.external_url.is_empty()); + assert_eq!(thread.external_url, ""); } #[test] @@ -1149,7 +1118,7 @@ mod tests { clippy::panic_in_result_fn, reason = "test assertions intentionally panic on failure" )] - fn animated_gif_banner_falls_back_to_gif_without_ffmpeg() -> Result<()> { + fn animated_gif_banner_converts_to_animated_webp_without_ffmpeg() -> Result<()> { let mut bytes = Vec::new(); { let mut encoder = GifEncoder::new(&mut bytes); @@ -1161,34 +1130,31 @@ mod tests { } let target = std::env::temp_dir().join(format!("{}.webp", uuid::Uuid::new_v4().simple())); let gif_path = banner_gif_fallback_path(&target); - let dimensions = write_animated_gif_banner_asset_scaled_with_caps( - &bytes, - &target, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - false, - false, - )?; + let dimensions = + write_animated_gif_banner_asset_scaled(&bytes, &target, DISPLAY_WIDTH, DISPLAY_HEIGHT)?; assert_eq!( dimensions, (DISPLAY_WIDTH, DISPLAY_HEIGHT), "fallback must preserve source dimensions" ); + assert!(target.exists(), "WebP must be produced without FFmpeg"); + let decoder = image::codecs::webp::WebPDecoder::new(std::io::BufReader::new( + std::fs::File::open(&target)?, + ))?; assert!( - !target.exists(), - "the unavailable WebP output must not be published" + decoder.has_animation(), + "banner animation must survive conversion" ); assert_eq!( - std::fs::read(&gif_path)?, - bytes, - "the GIF fallback must preserve the source bytes" + decoder.into_frames().collect_frames()?.len(), + 2, + "both banner frames must survive" ); - assert_eq!( - banner_asset_content_type(&gif_path), - "image/gif", - "the fallback must be served as GIF" + assert!( + !gif_path.exists(), + "successful conversion must not leave a GIF fallback" ); - drop(std::fs::remove_file(gif_path)); + drop(std::fs::remove_file(target)); Ok(()) } diff --git a/src/config.rs b/src/config.rs index 20c69257..a5c2174f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -535,12 +535,10 @@ struct SettingsFile { /// multiple instances that share the same storage to avoid key collisions. /// Default: "rustchan". tor_service_nickname: Option, - /// Whether startup fails when `FFmpeg` or `FFprobe` is unavailable. + /// Whether startup fails when `FFmpeg` is unavailable. require_ffmpeg: Option, /// Configured `FFmpeg` executable path. ffmpeg_path: Option, - /// Configured `FFprobe` executable path. - ffprobe_path: Option, /// Whether boards may enable arbitrary-file uploads. enable_any_file_uploads_feature: Option, /// How often to run PRAGMA `wal_checkpoint(TRUNCATE)`, in seconds. @@ -965,8 +963,6 @@ pub struct Config { pub require_ffmpeg: bool, /// Explicit ffmpeg binary path, or plain "ffmpeg" for PATH lookup. pub ffmpeg_path: String, - /// Explicit ffprobe binary path, or plain "ffprobe" for PATH lookup. - pub ffprobe_path: String, /// Global feature gate for arbitrary uploads. Boards can only enable the /// per-board toggle when this is true. pub enable_any_file_uploads_feature: bool, @@ -1103,7 +1099,6 @@ impl std::fmt::Debug for Config { .field("tor_service_nickname", &self.tor_service_nickname) .field("require_ffmpeg", &self.require_ffmpeg) .field("ffmpeg_path", &self.ffmpeg_path) - .field("ffprobe_path", &self.ffprobe_path) .field( "enable_any_file_uploads_feature", &self.enable_any_file_uploads_feature, @@ -1387,11 +1382,6 @@ impl Config { .ok() .or(s.ffmpeg_path) .unwrap_or_else(|| "ffmpeg".to_owned()), - ffprobe_path: environment - .var("CHAN_FFPROBE_PATH") - .ok() - .or(s.ffprobe_path) - .unwrap_or_else(|| "ffprobe".to_owned()), enable_any_file_uploads_feature: environment.boolean( "CHAN_ENABLE_ANY_FILE_UPLOADS_FEATURE", s.enable_any_file_uploads_feature.unwrap_or(false), @@ -2527,7 +2517,6 @@ mod tests { tor_service_nickname: "rustchan".to_owned(), require_ffmpeg: false, ffmpeg_path: "ffmpeg".to_owned(), - ffprobe_path: "ffprobe".to_owned(), enable_any_file_uploads_feature: false, bind_addr: "0.0.0.0:8080".to_owned(), database_path: "chan.db".to_owned(), diff --git a/src/config/admin.rs b/src/config/admin.rs index 6ffbc6c3..3e6b18b7 100644 --- a/src/config/admin.rs +++ b/src/config/admin.rs @@ -189,7 +189,6 @@ pub(super) fn startup_network_sources( } "require_ffmpeg" => settings.require_ffmpeg.is_some(), "ffmpeg_path" => settings.ffmpeg_path.is_some(), - "ffprobe_path" => settings.ffprobe_path.is_some(), "thumb_size" => settings.thumb_size.is_some(), "job_queue_capacity" => settings.job_queue_capacity.is_some(), "waveform_cache_max_mb" => settings.waveform_cache_max_mb.is_some(), diff --git a/src/config/admin/runtime.rs b/src/config/admin/runtime.rs index 4899bc8f..05db4b9f 100644 --- a/src/config/admin/runtime.rs +++ b/src/config/admin/runtime.rs @@ -134,25 +134,10 @@ pub fn save_section( let after = super::rewrite_root_settings(&before, &updates)?; let saved = super::resolve_file(&after, &Environment::Values(&empty))?; validate_section(section, &saved)?; - if section == RuntimeSection::Media { - for (tool, old, new, required) in [ - ( - "ffmpeg", - &previous.ffmpeg_path, - &saved.ffmpeg_path, - saved.require_ffmpeg, - ), - ( - "ffprobe", - &previous.ffprobe_path, - &saved.ffprobe_path, - false, - ), - ] { - if old != new || required { - probe_tool(tool, new)?; - } - } + if section == RuntimeSection::Media + && (previous.ffmpeg_path != saved.ffmpeg_path || saved.require_ffmpeg) + { + probe_tool("ffmpeg", &saved.ffmpeg_path)?; } super::save_root_at(&path, &updates, &Environment::Process, |candidate| { validate_section(section, candidate) @@ -240,9 +225,8 @@ static MEDIA_SETTINGS: &[SettingDefinition] = &[ SettingDefinition { key: "max_video_size_mb", label: "New-board video upload default (MiB)", environment: "CHAN_MAX_VIDEO_MB", kind: InputKind::Number(1, 2048), value: |c| (c.max_video_size / (1024 * 1024)).to_string(), help: "Upload limit for newly created boards; edit existing per-board caps under Boards." }, SettingDefinition { key: "max_audio_size_mb", label: "New-board audio upload default (MiB)", environment: "CHAN_MAX_AUDIO_MB", kind: InputKind::Number(1, 512), value: |c| (c.max_audio_size / (1024 * 1024)).to_string(), help: "Upload limit for newly created boards; existing boards retain their stored caps." }, SettingDefinition { key: "enable_any_file_uploads_feature", label: "Master arbitrary-file upload gate", environment: "CHAN_ENABLE_ANY_FILE_UPLOADS_FEATURE", kind: InputKind::Boolean, value: |c| c.enable_any_file_uploads_feature.to_string(), help: "Both this global gate and the per-board arbitrary-file checkbox must be enabled. This does not change individual board preferences." }, - SettingDefinition { key: "require_ffmpeg", label: "Require FFmpeg at startup", environment: "CHAN_REQUIRE_FFMPEG", kind: InputKind::Boolean, value: |c| c.require_ffmpeg.to_string(), help: "Missing FFmpeg becomes a startup error. FFprobe is detected separately; this flag does not make missing FFprobe fatal." }, + SettingDefinition { key: "require_ffmpeg", label: "Require FFmpeg at startup", environment: "CHAN_REQUIRE_FFMPEG", kind: InputKind::Boolean, value: |c| c.require_ffmpeg.to_string(), help: "Missing FFmpeg becomes a startup error. Internal Rust image, metadata, PDF and common-audio processing does not need FFmpeg." }, SettingDefinition { key: "ffmpeg_path", label: "FFmpeg executable", environment: "CHAN_FFMPEG_PATH", kind: InputKind::Text, value: |c| c.ffmpeg_path.clone(), help: "Use ffmpeg for PATH lookup, or an absolute executable path. New tool paths are checked with a bounded version probe before saving." }, - SettingDefinition { key: "ffprobe_path", label: "FFprobe executable", environment: "CHAN_FFPROBE_PATH", kind: InputKind::Text, value: |c| c.ffprobe_path.clone(), help: "Use ffprobe for PATH lookup, or an absolute executable path. Missing FFprobe reduces media metadata support." }, SettingDefinition { key: "thumb_size", label: "Generated thumbnail dimension (pixels)", environment: "CHAN_THUMB_SIZE", kind: InputKind::Number(16, 4096), value: |c| c.thumb_size.to_string(), help: "Applies to thumbnails generated after restart. Existing thumbnails are not regenerated." }, SettingDefinition { key: "job_queue_capacity", label: "Background queue capacity (pending jobs)", environment: "CHAN_JOB_QUEUE_CAPACITY", kind: InputKind::Number(0, 1_000_000), value: |c| c.job_queue_capacity.to_string(), help: "0 is unlimited. Once full, the queue drops new media jobs with a warning. Current queue pressure is shown in Site Health." }, SettingDefinition { key: "waveform_cache_max_mb", label: "Thumbnail/waveform cache budget (MiB)", environment: "CHAN_WAVEFORM_CACHE_MAX_MB", kind: InputKind::Number(0, 1_048_576), value: |c| (c.waveform_cache_max_bytes / (1024 * 1024)).to_string(), help: "Oldest cache files are evicted by a background task when the budget is exceeded. 0 disables eviction." }, @@ -313,8 +297,8 @@ mod tests { ); } ensure!( - keys.len() == 63, - "registry must cover 39 inventoried root controls, 12 TLS leaves and 12 new operator choices" + keys.len() == 62, + "registry must cover 38 inventoried root controls, 12 TLS leaves and 12 new operator choices" ); let config = resolve_file("", &Environment::Values(&BTreeMap::new()))?; ensure!( @@ -467,7 +451,6 @@ mod tests { ); } validate_tool_path("ffmpeg", "ffmpeg")?; - validate_tool_path("ffprobe", "ffprobe")?; Ok(()) } diff --git a/src/config/template.rs b/src/config/template.rs index 452403ba..4bc7a92a 100644 --- a/src/config/template.rs +++ b/src/config/template.rs @@ -142,8 +142,6 @@ ffmpeg_timeout_secs = 600 # Optional explicit ffmpeg binary path. Leave unset to use PATH lookup. # ffmpeg_path = "/usr/local/bin/ffmpeg" -# Optional explicit ffprobe binary path. Leave unset to use PATH lookup. -# ffprobe_path = "/usr/local/bin/ffprobe" # Automatically prune oldest full-size post media when active stored post media # exceeds the configured byte cap. Thumbnails are kept when possible so old diff --git a/src/detect.rs b/src/detect.rs index f6f7ff05..80441b74 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -67,29 +67,6 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { } } -/// Probe the configured `ffprobe` path at startup so bogus explicit paths are -/// detected immediately instead of only failing later on the first `WebM` probe. -pub fn detect_ffprobe() -> bool { - let ok = probe_tool(&crate::config::CONFIG.ffprobe_path); - - if ok { - tracing::info!( - target: "rustchan::detect", - available = true, - "ffprobe detected — WebM codec inspection enabled" - ); - } else { - tracing::warn!( - target: "rustchan::detect", - available = false, - ffprobe_path = %crate::config::CONFIG.ffprobe_path, - "ffprobe not detected — WebM codec inspection will fail for uploads that need it" - ); - } - - ok -} - /// Probe whether the detected ffmpeg has `libwebp` compiled in. pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { if !ffmpeg_ok { @@ -102,13 +79,13 @@ pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { tracing::info!( target: "rustchan::detect", webp = true, - "ffmpeg libwebp encoder available — image to WebP conversion enabled" + "ffmpeg libwebp encoder available — video-frame thumbnails enabled" ); } else { tracing::warn!( target: "rustchan::detect", webp = false, - "ffmpeg libwebp encoder missing — JPEG/PNG/BMP/TIFF stored in original format" + "ffmpeg libwebp encoder missing — video-frame thumbnails use placeholders" ); if crate::logging::is_tty() { crate::logging::console_print_raw(&webp_install_hint()); @@ -118,40 +95,10 @@ pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { has_webp } -/// Detects the available external PDF thumbnail renderers in priority order. +/// Reports the built-in pure-Rust PDF preview renderer. +#[must_use] pub fn detect_pdf_thumbnail_renderers() -> Vec { - let renderers = crate::media::thumbnail::detect_pdf_renderers(); - - if renderers.is_empty() { - tracing::warn!( - target: "rustchan::detect", - available = false, - "no PDF thumbnail renderer detected — PDF uploads still work and will use the built-in generic PDF thumbnail. Install Poppler pdftoppm, MuPDF mutool, or use macOS qlmanage to enable real first-page thumbnails" - ); - if crate::logging::is_tty() { - crate::logging::console_print_raw( - " PDF uploads still work with a built-in generic thumbnail.\n Install Poppler `pdftoppm`, MuPDF `mutool`, or use macOS `qlmanage` for real first-page thumbnails.\n\n", - ); - } - } else { - let detected = renderers - .iter() - .map(|renderer| renderer.binary_name()) - .collect::>() - .join(", "); - let selected = renderers - .first() - .map_or("unknown", |renderer| renderer.binary_name()); - tracing::info!( - target: "rustchan::detect", - available = true, - renderers = %detected, - selected = selected, - "PDF thumbnail renderer detected" - ); - } - - renderers + crate::media::thumbnail::detect_pdf_renderers() } /// Builds the platform-specific `WebP` encoder installation hint. @@ -814,7 +761,7 @@ mod tests { use std::os::unix::fs::symlink; let tempdir = tempfile::tempdir().context("create temporary directory")?; - let script = tempdir.path().join("ffprobe"); + let script = tempdir.path().join("fixture-probe"); symlink("/usr/bin/true", &script).context("symlink true as the probe executable")?; assert!( @@ -826,7 +773,7 @@ mod tests { "the explicit executable path must be probed" ); assert!( - !probe_tool("/definitely/not/a/real/ffprobe"), + !probe_tool("/definitely/not/a/real/fixture-probe"), "a nonexistent executable path must fail the probe" ); Ok(()) diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index 7b5acee8..00b5b4b5 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -614,7 +614,6 @@ struct AdminPanelSnapshot { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, @@ -814,7 +813,6 @@ struct MaintenanceDomainData { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, @@ -1306,7 +1304,6 @@ fn load_maintenance_domain_data( media_auto_prune_enabled: db::get_media_auto_prune_enabled(conn), media_max_active_content_size_bytes: db::get_media_max_active_content_size_bytes(conn), ffmpeg_available: state.ffmpeg_available, - ffprobe_available: state.ffprobe_available, ffmpeg_webp_available: state.ffmpeg_webp_available, ffmpeg_vp9_available: state.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: state.ffmpeg_vp9_encoder_available, @@ -1386,7 +1383,6 @@ fn load_admin_panel_snapshot( media_max_active_content_size_bytes: maintenance_domain .media_max_active_content_size_bytes, ffmpeg_available: maintenance_domain.ffmpeg_available, - ffprobe_available: maintenance_domain.ffprobe_available, ffmpeg_webp_available: maintenance_domain.ffmpeg_webp_available, ffmpeg_vp9_available: maintenance_domain.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: maintenance_domain.ffmpeg_vp9_encoder_available, @@ -1664,15 +1660,14 @@ fn dashboard_dependency_status( ffmpeg_required: bool, ) -> (String, String, crate::templates::AdminDashboardState) { let ffmpeg = detection_word(maintenance.ffmpeg_available); - let ffprobe = detection_word(maintenance.ffprobe_available); - let state = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + let state = if maintenance.ffmpeg_available { crate::templates::AdminDashboardState::Ok } else if ffmpeg_required { crate::templates::AdminDashboardState::ActionNeeded } else { crate::templates::AdminDashboardState::Informational }; - let status = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + let status = if maintenance.ffmpeg_available { "ready" } else if ffmpeg_required { "required tool missing" @@ -1682,7 +1677,7 @@ fn dashboard_dependency_status( ( status.to_owned(), format!( - "ffmpeg {ffmpeg}; ffprobe {ffprobe}; WebP {}; VP9 {}; Opus {}.", + "ffmpeg {ffmpeg}; WebP {}; VP9 {}; Opus {}.", detection_word(maintenance.ffmpeg_webp_available), detection_word(maintenance.ffmpeg_vp9_encoder_available), detection_word(maintenance.ffmpeg_opus_available) @@ -1916,11 +1911,6 @@ fn render_admin_panel_from_snapshot( } else { crate::templates::AdminDetectionStatus::Missing }, - ffprobe: if snapshot.ffprobe_available { - crate::templates::AdminDetectionStatus::Detected - } else { - crate::templates::AdminDetectionStatus::Missing - }, webp_encoder: if snapshot.ffmpeg_webp_available { crate::templates::AdminDetectionStatus::Detected } else { @@ -2003,7 +1993,6 @@ fn build_site_health_view<'a>( tor_detail: &snapshot.dashboard.tor_detail, dependency_summary: crate::templates::AdminSiteHealthDependencySummary { ffmpeg: detection_status(snapshot.ffmpeg_available), - ffprobe: detection_status(snapshot.ffprobe_available), webp: detection_status(snapshot.ffmpeg_webp_available), vp9: detection_status(snapshot.ffmpeg_vp9_encoder_available), opus: detection_status(snapshot.ffmpeg_opus_available), @@ -2049,7 +2038,6 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st OS: {os}-{arch}\n\ SQLite: {sqlite}\n\ ffmpeg: {ffmpeg}\n\ - ffprobe: {ffprobe}\n\ Tor enabled: {tor_enabled} ({tor_detail})\n\ TLS enabled: {tls_enabled}\n\ Reverse proxy: {reverse_proxy}\n\ @@ -2063,7 +2051,6 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st arch = std::env::consts::ARCH, sqlite = rusqlite::version(), ffmpeg = detection_word(snapshot.ffmpeg_available), - ffprobe = detection_word(snapshot.ffprobe_available), warnings = indent_diagnostics_block(&snapshot.site_health.recent_warnings), ) } @@ -2755,7 +2742,7 @@ mod tests { assert_eq!(state, crate::templates::AdminDashboardState::Failure); } - fn maintenance_with_media_tools(ffmpeg: bool, ffprobe: bool) -> MaintenanceDomainData { + fn maintenance_with_media_tools(ffmpeg: bool) -> MaintenanceDomainData { MaintenanceDomainData { db_size_bytes: 0, db_size_warning: false, @@ -2763,7 +2750,6 @@ mod tests { media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, ffmpeg_available: ffmpeg, - ffprobe_available: ffprobe, ffmpeg_webp_available: false, ffmpeg_vp9_available: false, ffmpeg_vp9_encoder_available: false, @@ -2774,7 +2760,7 @@ mod tests { #[test] fn dashboard_optional_media_tools_are_informational_not_warning() { - let maintenance = maintenance_with_media_tools(false, false); + let maintenance = maintenance_with_media_tools(false); assert_eq!( dashboard_dependency_status(&maintenance, false).2, diff --git a/src/handlers/board/access_preferences.rs b/src/handlers/board/access_preferences.rs index ad8c5582..e46a802b 100644 --- a/src/handlers/board/access_preferences.rs +++ b/src/handlers/board/access_preferences.rs @@ -1324,7 +1324,7 @@ mod tests { chrono::Utc::now().timestamp() - 1, ); - assert!(owned_post_grants_from_jar(&jar).is_empty()); + assert_eq!(owned_post_grants_from_jar(&jar).len(), 0); } #[test] @@ -1387,7 +1387,7 @@ mod tests { value, )); - assert!(owned_post_grants_from_jar(&jar).is_empty()); + assert_eq!(owned_post_grants_from_jar(&jar).len(), 0); } } diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index 878fe4a4..d1c7e388 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -130,7 +130,6 @@ pub(in crate::server) async fn create_thread( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffprobe_available = state.ffprobe_available; let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is @@ -165,7 +164,6 @@ pub(in crate::server) async fn create_thread( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, ) diff --git a/src/handlers/board/media.rs b/src/handlers/board/media.rs index d1f24bfd..065e67fb 100644 --- a/src/handlers/board/media.rs +++ b/src/handlers/board/media.rs @@ -68,17 +68,36 @@ fn stale_webm_redirect_path(base: &std::path::Path, media_path: &str) -> Option< let path = std::path::Path::new(media_path); if !path .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) + .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("mkv")) { return None; } let stem = media_path.get(..media_path.len().saturating_sub(4))?; let webm_path = format!("{stem}.webm"); safe_board_media_file(base, &webm_path).ok()?; - Some(format!("/boards/{webm_path}")) + let location = format!("/boards/{webm_path}"); + HeaderValue::from_str(&location).ok()?; + axum::http::Uri::try_from(location.as_str()).ok()?; + Some(location) } -// Legacy `.mp4` links redirect permanently to transcoded `.webm` files; all +/// A page can still request its SVG just after the worker replaces that file. +/// Resolve only generated thumbnail paths and validate the PNG under the root. +fn stale_waveform_redirect_path(base: &std::path::Path, media_path: &str) -> Option { + if !is_generated_svg_placeholder_thumb(media_path) { + return None; + } + let png_path = std::path::Path::new(media_path).with_extension("png"); + let relative = png_path.to_str()?; + safe_board_media_file(base, relative).ok()?; + let location = format!("/boards/{relative}"); + // Validate untrusted stored paths before constructing an HTTP redirect. + HeaderValue::from_str(&location).ok()?; + axum::http::Uri::try_from(location.as_str()).ok()?; + Some(location) +} + +// Legacy `.mp4` and `.mkv` links redirect to transcoded `.webm` files; all // other validated paths are served directly. #[expect( @@ -152,9 +171,12 @@ pub(in crate::server) async fn serve_board_media( Ok(path) => Some(path), Err(error) if is_not_found_error(&error) - && std::path::Path::new(&media_path) + && (std::path::Path::new(&media_path) .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) => + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("mkv") + }) + || is_generated_svg_placeholder_thumb(&media_path)) => { None } @@ -171,6 +193,20 @@ pub(in crate::server) async fn serve_board_media( ServeFile::new(&target).oneshot(req).await.map_or_else( |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), |resp| { + // The waveform worker can remove the SVG between our path check + // and ServeFile opening it. Resolve the completed PNG again. + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_waveform_redirect_path(&base, &media_path)) + .flatten() + { + return Redirect::temporary(&path).into_response(); + } + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_webm_redirect_path(&base, &media_path)) + .flatten() + { + return Redirect::permanent(&path).into_response(); + } let mut resp = resp.map(axum::body::Body::new); crate::cache::set_cache_control( resp.headers_mut(), @@ -216,6 +252,8 @@ pub(in crate::server) async fn serve_board_media( resp.into_response() }, ) + } else if let Some(redirect_path) = stale_waveform_redirect_path(&base, &media_path) { + Redirect::temporary(&redirect_path).into_response() } else if let Some(redirect_path) = stale_webm_redirect_path(&base, &media_path) { Redirect::permanent(&redirect_path).into_response() } else { @@ -408,6 +446,25 @@ mod tests { use super::stale_webm_redirect_path; use anyhow::{ensure, Context as _, Result as AnyResult}; + #[test] + fn stale_waveform_svg_redirects_only_to_an_existing_generated_png() -> AnyResult<()> { + let dir = tempfile::tempdir()?; + let thumbs = dir.path().join("test/thumbs"); + std::fs::create_dir_all(&thumbs)?; + let path = "test/thumbs/clip.svg"; + ensure!(super::stale_waveform_redirect_path(dir.path(), path).is_none()); + std::fs::write(thumbs.join("clip.png"), b"fixture")?; + ensure!( + super::stale_waveform_redirect_path(dir.path(), path).as_deref() + == Some("/boards/test/thumbs/clip.png") + ); + ensure!(super::stale_waveform_redirect_path(dir.path(), "test/clip.svg").is_none()); + ensure!( + super::stale_waveform_redirect_path(dir.path(), "../test/thumbs/clip.svg").is_none() + ); + Ok(()) + } + #[test] fn stale_mp4_redirect_path_accepts_valid_webm_sibling() -> AnyResult<()> { let tempdir = tempfile::tempdir().context("create temporary upload root")?; @@ -416,10 +473,13 @@ mod tests { std::fs::create_dir_all(&board_dir).context("create board directory")?; std::fs::write(board_dir.join("clip.webm"), b"webm").context("write WebM fixture")?; - ensure!( - stale_webm_redirect_path(&upload_root, "test/clip.mp4").as_deref() - == Some("/boards/test/clip.webm") - ); + for path in ["test/clip.mp4", "test/clip.mkv", "test/clip.MKV"] { + ensure!( + stale_webm_redirect_path(&upload_root, path).as_deref() + == Some("/boards/test/clip.webm") + ); + } + ensure!(stale_webm_redirect_path(&upload_root, "test/clip.wav").is_none()); Ok(()) } @@ -438,6 +498,7 @@ mod tests { unix_fs::symlink(&outside, board_dir.join("link")).context("create escaping symlink")?; ensure!(stale_webm_redirect_path(&upload_root, "test/link/clip.mp4").is_none()); + ensure!(stale_webm_redirect_path(&upload_root, "test/link/clip.mkv").is_none()); Ok(()) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 2f8ad974..eed4192e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1274,7 +1274,6 @@ pub(crate) fn process_primary_upload( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ) -> Result<(Option, Option)> { let Some((upload, fname)) = file_data else { @@ -1285,7 +1284,6 @@ pub(crate) fn process_primary_upload( let detected_mime = crate::utils::files::classify_upload_mime( upload.temp_file.path(), &upload.sniff_bytes, - ffprobe_available, allow_any_files, ) .map_err(|error| classify_upload_error(&error))?; @@ -1343,7 +1341,6 @@ pub(crate) fn process_primary_upload( max_audio_size, max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, allow_any_files, }; @@ -1426,15 +1423,10 @@ fn upload_path_belongs_to_board(path: &str, board_short: &str) -> bool { path.split('/').next() == Some(board_short) } -fn temp_upload_mime( - upload: &TempUpload, - ffprobe_available: bool, - allow_any_files: bool, -) -> Result { +fn temp_upload_mime(upload: &TempUpload, allow_any_files: bool) -> Result { crate::utils::files::classify_upload_mime( upload.temp_file.path(), &upload.sniff_bytes, - ffprobe_available, allow_any_files, ) .map_err(|error| classify_upload_error(&error)) @@ -1451,7 +1443,6 @@ pub(crate) fn process_audio_combo( board: &Board, upload_dir: &str, max_audio_size: usize, - ffprobe_available: bool, ) -> Result> { let Some((audio_upload, aud_fname)) = audio_file_data else { return Ok(None); @@ -1480,7 +1471,6 @@ pub(crate) fn process_audio_combo( upload_dir, &board.short_name, max_audio_size, - ffprobe_available, ) .map_err(|e| classify_upload_error(&e))?; @@ -1509,7 +1499,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffprobe_available: bool, ffmpeg_webp_available: bool, ) -> Result<( Option, @@ -1532,7 +1521,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size, max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, ) }; @@ -1553,14 +1541,13 @@ pub(crate) fn process_audio_first_uploads( board, save_root_str, max_audio_size, - ffprobe_available, )?; return Ok((primary, audio, primary_hash)); } if let Some((audio_upload, audio_name)) = audio_file_data { - let audio_mime = temp_upload_mime(&audio_upload, ffprobe_available, allow_any_files)?; + let audio_mime = temp_upload_mime(&audio_upload, allow_any_files)?; if crate::models::MediaType::from_mime(&audio_mime) != crate::models::MediaType::Audio { return Err(AppError::BadRequest( "The audio slot only accepts audio files.".into(), @@ -2521,7 +2508,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2585,7 +2571,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2652,7 +2637,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; @@ -2694,7 +2678,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2738,7 +2721,6 @@ trailer << /Root 1 0 R >> board.max_pdf_size_bytes(), false, false, - false, ); let Err(error) = result else { @@ -2778,7 +2760,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ); let Err(error) = result else { @@ -2822,7 +2803,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, false, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index aae654cc..e4ad6373 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -49,7 +49,6 @@ pub(super) struct SubmitPostCommand { pub upload_dir: String, pub thumb_size: u32, pub ffmpeg_available: bool, - pub ffprobe_available: bool, pub ffmpeg_webp_available: bool, } @@ -94,7 +93,6 @@ struct UploadConfig<'a> { pub max_audio_size: usize, pub max_pdf_size: usize, pub ffmpeg_available: bool, - pub ffprobe_available: bool, pub ffmpeg_webp_available: bool, } @@ -422,7 +420,6 @@ fn process_uploads( config.max_audio_size, config.max_pdf_size, config.ffmpeg_available, - config.ffprobe_available, config.ffmpeg_webp_available, ); @@ -533,7 +530,6 @@ pub(super) fn submit_post( upload_dir, thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, } = command; @@ -619,7 +615,6 @@ pub(super) fn submit_post( max_audio_size: effective_max_audio_size, max_pdf_size: effective_max_pdf_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, )?; @@ -922,7 +917,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -960,7 +954,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -994,7 +987,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, } } @@ -1027,7 +1019,7 @@ mod tests { } fn flac_header_bytes() -> Vec { - b"fLaC\x00\x00\x00\x22tiny test flac bytes".to_vec() + include_bytes!("../../tests/fixtures/media/tone.flac").to_vec() } fn malformed_aac_bytes() -> Result> { @@ -1043,7 +1035,7 @@ mod tests { } fn mp4_header_bytes() -> Vec { - b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2mp41".to_vec() + include_bytes!("../../tests/fixtures/media/video.mp4").to_vec() } fn pending_upload_stage_count(upload_dir: &std::path::Path) -> Result { @@ -1512,7 +1504,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, ); @@ -1640,7 +1631,10 @@ mod tests { match error { AppError::BadRequest(message) => { - assert!(message.contains("ADTS stream is malformed")); + assert!( + message.contains("ADTS stream is malformed") + || message.contains("could not validate") + ); } other => bail!("expected BadRequest, got {other:?}"), } @@ -1923,7 +1917,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, ) @@ -2010,7 +2003,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffprobe_available: false, ffmpeg_webp_available: false, }, )?; diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs index 22648852..df62617c 100644 --- a/src/handlers/setup.rs +++ b/src/handlers/setup.rs @@ -1054,11 +1054,6 @@ fn setup_form_page( } else { "missing" }; - let ffprobe = if app_state.ffprobe_available { - "detected" - } else { - "missing" - }; let body = format!( r#"
@@ -1105,7 +1100,7 @@ fn setup_form_page(

The slug becomes the board URL and must contain 1–8 lowercase letters or digits.

Step 6

Uploads and media

Enable only the formats this instance should accept and set whole-MiB limits.

-

Runtime detection: ffmpeg {ffmpeg}; ffprobe {ffprobe}.

+

Runtime detection: ffmpeg {ffmpeg}; image/audio inspection built in.

@@ -1169,7 +1164,6 @@ fn setup_form_page( allow_self_delete = checked(form.allow_self_delete.as_deref()), allow_archive = checked(form.allow_archive.as_deref()), ffmpeg = ffmpeg, - ffprobe = ffprobe, allow_uploads = checked(form.allow_uploads.as_deref()), allow_video = checked(form.allow_video.as_deref()), allow_audio = checked(form.allow_audio.as_deref()), diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 1c693b0e..f87dbdef 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -396,7 +396,6 @@ pub(in crate::server) async fn post_reply( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffprobe_available = state.ffprobe_available; let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is @@ -426,7 +425,6 @@ pub(in crate::server) async fn post_reply( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffprobe_available, ffmpeg_webp_available, }, ) @@ -1577,10 +1575,8 @@ mod tests { } fn flac_fixture(size: usize) -> Vec { - let mut bytes = vec![0_u8; size.max(4)]; - if let Some(prefix) = bytes.get_mut(..4) { - prefix.copy_from_slice(b"fLaC"); - } + let mut bytes = include_bytes!("../../tests/fixtures/media/tone.flac").to_vec(); + bytes.resize(size.max(bytes.len()), 0); bytes } @@ -1602,7 +1598,7 @@ mod tests { #[tokio::test] async fn create_thread_and_reply_accept_audio_within_board_limit() -> Result<()> { let state = crate::test_support::app_state(); - seed_audio_board(&state, 5_000)?; + seed_audio_board(&state, 20_000)?; let router = Router::new() .route("/{board}", post(crate::handlers::board::create_thread)) @@ -1673,13 +1669,13 @@ mod tests { #[tokio::test] async fn create_thread_rejects_audio_over_board_limit_with_413() -> Result<()> { let state = crate::test_support::app_state(); - seed_audio_board(&state, 5_000)?; + seed_audio_board(&state, 20_000)?; let router = Router::new() .route("/{board}", post(crate::handlers::board::create_thread)) .with_state(state); - let audio = flac_fixture(5_001); + let audio = flac_fixture(20_001); let (boundary, body) = crate::test_support::multipart_body( &[("_csrf", "csrf123"), ("body", "")], Some(("audio_file", "too-large.flac", &audio, "audio/flac")), diff --git a/src/media/audio.rs b/src/media/audio.rs new file mode 100644 index 00000000..7e8346b8 --- /dev/null +++ b/src/media/audio.rs @@ -0,0 +1,726 @@ +//! Incremental PCM decoding and deterministic, bounded-memory waveform images. + +use anyhow::{ensure, Context as _, Result}; +use image::{Rgba, RgbaImage}; +use opus_decoder::OpusDecoder; +use std::path::Path; +use std::time::{Duration, Instant}; +use symphonia::core::codecs::audio::well_known::CODEC_ID_OPUS; +use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions}; +use symphonia::core::formats::TrackType; +use symphonia::core::io::{BitReaderLtr, FiniteBitStream as _, ReadBitsLtr as _}; +use symphonia::core::packet::Packet; +use tokio_util::sync::CancellationToken; + +/// Distinguish unavailable codecs from malformed input and resource failures. +pub(crate) fn is_unsupported(error: &anyhow::Error) -> bool { + // Symphonia also calls missing MP4 atoms and excessive MKV geometry + // Unsupported. Only audited codec limitations may select compatibility. + // Opus InternalError includes transform failures, so it must fail closed. + error.chain().any(|cause| { + matches!( + cause.downcast_ref::(), + Some(symphonia::core::errors::Error::Unsupported( + "Opus multistream requires the compatibility decoder" + | "AAC profile needs the compatibility decoder" + | "audio codec needs the compatibility decoder" + | "Speex requires the compatibility decoder" + | "aac: aac too complex" + )) + ) + }) +} + +/// Maximum interleaved samples retained for a single decoded audio packet. +const MAX_PACKET_SAMPLES: usize = 4 * 1024 * 1024; + +/// Audio codecs share one packet-to-interleaved-PCM boundary. +enum PacketDecoder { + /// Mature general-purpose pure-Rust audio decoders. + Symphonia(Box), + /// Single Opus stream, mono or stereo. + Opus(Box), +} + +impl PacketDecoder { + /// Select a decoder from validated container metadata. + fn new(params: &AudioCodecParameters, channels: usize) -> Result { + if params.codec != CODEC_ID_OPUS { + if let Some(frames) = params.max_frames_per_packet { + ensure!( + u128::from(frames) * u128::try_from(channels)? + <= u128::try_from(MAX_PACKET_SAMPLES)?, + "audio packet exceeds sample budget" + ); + } + return Ok(Self::Symphonia( + symphonia::default::get_codecs() + .make_audio_decoder(params, &AudioDecoderOptions::default())?, + )); + } + let header = params + .extra_data + .as_deref() + .context("Opus header missing")?; + ensure!(header.get(..8) == Some(b"OpusHead"), "invalid Opus header"); + ensure!( + header.get(9).copied().map(usize::from) == Some(channels), + "Opus channels disagree with container" + ); + let mapping = *header.get(18).context("truncated Opus mapping")?; + if mapping == 0 { + ensure!(channels <= 2, "invalid single-stream Opus channel count"); + Ok(Self::Opus(Box::new(OpusDecoder::new(48_000, channels)?))) + } else { + let streams = usize::from(*header.get(19).context("truncated Opus stream count")?); + let coupled = usize::from(*header.get(20).context("truncated Opus coupled count")?); + let channel_map = header + .get(21..21 + channels) + .context("truncated Opus channel map")?; + ensure!( + streams > 0 && coupled <= streams && streams + coupled <= 255, + "invalid Opus stream mapping" + ); + ensure!( + channel_map + .iter() + .all(|slot| *slot == 255 || usize::from(*slot) < streams + coupled), + "invalid Opus channel mapping" + ); + // The current focused decoder's multistream packet splitter is not + // RFC 6716 Appendix B compatible (a valid libopus 5.1 fixture fails). + // Preserve this variant through the authorized compatibility path. + Err(symphonia::core::errors::Error::Unsupported( + "Opus multistream requires the compatibility decoder", + ) + .into()) + } + } + + /// Decode one packet into a reused bounded float buffer. + fn decode(&mut self, packet: &Packet, channels: usize, samples: &mut Vec) -> Result<()> { + match self { + Self::Symphonia(decoder) => { + let pcm = decoder.decode(packet).context("decode audio packet")?; + let count = pcm.samples_interleaved(); + ensure!( + count <= MAX_PACKET_SAMPLES, + "decoded audio packet exceeds sample budget" + ); + ensure!( + pcm.spec().channels().count() == channels, + "audio channel count changed" + ); + samples.resize(count, 0.0); + pcm.copy_to_slice_interleaved(samples.as_mut_slice()); + } + Self::Opus(decoder) => { + ensure!(!packet.data.is_empty(), "empty Opus packet"); + samples.resize( + OpusDecoder::MAX_FRAME_SIZE_48K + .checked_mul(channels) + .context("Opus buffer overflow")?, + 0.0, + ); + let frames = decoder.decode_float(&packet.data, samples, false)?; + samples.truncate( + frames + .checked_mul(channels) + .context("Opus frame overflow")?, + ); + } + } + ensure!( + samples.iter().all(|sample| sample.is_finite()), + "non-finite PCM samples" + ); + Ok(()) + } +} + +/// Opus presentation metadata is independent of the PCM decoder state. +struct OpusPresentation { + /// Exact remaining delay from the identification header, in 48 kHz samples. + delay: usize, + /// Linear output gain from the signed Q8 decibel header field. + gain: f32, + /// Container timestamp units for packet trimming. + time_base: symphonia::core::units::TimeBase, +} + +impl OpusPresentation { + /// Read metadata from the already validated stream parameters. + fn new( + params: &AudioCodecParameters, + time_base: Option, + ) -> Result { + let header = params + .extra_data + .as_deref() + .context("Opus header missing")?; + let delay: [u8; 2] = header + .get(10..12) + .context("Opus delay missing")? + .try_into()?; + let gain: [u8; 2] = header + .get(16..18) + .context("Opus gain missing")? + .try_into()?; + Ok(Self { + delay: usize::from(u16::from_le_bytes(delay)), + // OpusHead gain is Q8 dB (RFC 7845 section 5.1). + gain: 10.0_f32.powf(f32::from(i16::from_le_bytes(gain)) / (256.0 * 20.0)), + time_base: time_base.context("Opus time base missing")?, + }) + } + + /// Return playable frames, removing exact header delay and container padding. + fn samples<'a>( + &mut self, + packet: &Packet, + samples: &'a [f32], + channels: usize, + ) -> Result<&'a [f32]> { + let to_frames = |duration: symphonia::core::units::Duration| -> Result { + let numerator = + u128::from(duration.get()) * u128::from(self.time_base.numer.get()) * 48_000; + let denominator = u128::from(self.time_base.denom.get()); + Ok(usize::try_from( + (numerator + denominator / 2) / denominator, + )?) + }; + // Ogg supplies exact trimming; Matroska can round delay to milliseconds. + let start = to_frames(packet.trim_start)? + .max(self.delay) + .min(samples.len() / channels); + self.delay = self.delay.saturating_sub(start); + let end = to_frames(packet.trim_end)?; + let first = start.checked_mul(channels).context("Opus trim overflow")?; + let last = samples + .len() + .checked_sub(end.checked_mul(channels).context("Opus trim overflow")?) + .context("Opus padding exceeds packet")?; + samples.get(first..last).context("Opus trim exceeds packet") + } +} + +/// Deadline/cancellation policy shared by both streaming waveform passes. +struct AudioBudget<'a> { + /// Time at the start of the job. + started: Instant, + /// Existing operator media-processing timeout. + timeout: Duration, + /// Worker shutdown cancellation token. + cancel: &'a CancellationToken, +} + +/// Detect backward-compatible HE-AAC signaling after an AAC-LC configuration. +/// +/// Symphonia 0.6.1 skips this sync extension when no explicit SBR prefix was +/// present, decoding only the low-rate core while reporting AAC-LC. Inspect the +/// bounded configuration, including extension frequency, before choosing it. +fn aac_needs_compatibility(extra: &[u8]) -> Result { + ensure!(extra.len() <= 64, "AAC configuration exceeds safety budget"); + let mut bits = BitReaderLtr::new(extra); + let profile = bits.read_bits_leq32(5)?; + ensure!(matches!(profile, 1..=5 | 29), "unvalidated AAC profile"); + read_aac_frequency(&mut bits)?; + let channels = bits.read_bits_leq32(4)?; + ensure!(channels <= 7, "invalid AAC channel configuration"); + let explicit_sbr = matches!(profile, 5 | 29); + if explicit_sbr { + read_aac_frequency(&mut bits)?; + ensure!(bits.read_bits_leq32(5)? == 2, "invalid HE-AAC core profile"); + } + let _short_frame = bits.read_bool()?; + if bits.read_bool()? { + bits.ignore_bits(14)?; + } + if bits.read_bool()? { + let _extension_flag3 = bits.read_bool()?; + } + if explicit_sbr || profile != 2 { + return Ok(true); + } + // PCE channel layouts are handled by the decoder's explicit limitation. + if channels == 0 || bits.bits_left() < 16 { + return Ok(false); + } + if bits.read_bits_leq32(11)? != 0x2b7 { + return Ok(false); + } + ensure!( + bits.read_bits_leq32(5)? == 5, + "invalid AAC SBR extension type" + ); + let sbr = bits.read_bool()?; + if sbr { + read_aac_frequency(&mut bits)?; + if bits.bits_left() >= 12 && bits.read_bits_leq32(11)? == 0x548 { + let _parametric_stereo = bits.read_bool()?; + } + } + Ok(sbr) +} + +/// Validate a sampling-frequency index or a nonzero explicit 24-bit frequency. +fn read_aac_frequency(bits: &mut BitReaderLtr<'_>) -> Result<()> { + let index = bits.read_bits_leq32(4)?; + if index == 15 { + ensure!( + bits.read_bits_leq32(24)? > 0, + "invalid AAC sampling frequency" + ); + } else { + ensure!(index <= 12, "invalid AAC sampling frequency index"); + } + Ok(()) +} + +impl AudioBudget<'_> { + /// Check the deadline before decoding another packet or publishing pixels. + fn check(&self) -> Result<()> { + ensure!( + !self.cancel.is_cancelled(), + "audio processing cancelled during shutdown" + ); + ensure!( + self.started.elapsed() < self.timeout, + "audio processing timed out" + ); + Ok(()) + } +} + +/// Walk decoded channel frames without retaining the clip in memory. +fn visit_samples( + input: &Path, + budget: &AudioBudget<'_>, + mut visit: impl FnMut(f32) -> Result<()>, +) -> Result { + budget.check()?; + if super::probe::is_speex(input, budget.started, budget.timeout, Some(budget.cancel))? { + return Err(symphonia::core::errors::Error::Unsupported( + "Speex requires the compatibility decoder", + ) + .into()); + } + let (mut format, read_budget) = + super::probe::open_audio_format(input, budget.started, budget.timeout, budget.cancel)?; + let track = format + .first_track(TrackType::Audio) + .context("media has no audio track")?; + let track_id = track.id; + let params = track + .codec_params + .as_ref() + .and_then(symphonia::core::codecs::CodecParameters::audio) + .context("audio codec parameters missing")?; + if params.codec == symphonia::core::codecs::audio::well_known::CODEC_ID_AAC + && params.extra_data.as_deref().map_or_else( + || Ok(params.profile.is_some_and(|profile| { + profile != symphonia::core::codecs::audio::well_known::profiles::CODEC_PROFILE_AAC_LC + })), + aac_needs_compatibility, + )? + { + return Err(symphonia::core::errors::Error::Unsupported( + "AAC profile needs the compatibility decoder", + ) + .into()); + } + let is_opus = params.codec == CODEC_ID_OPUS; + if !is_opus + && symphonia::default::get_codecs() + .get_audio_decoder(params.codec) + .is_none() + { + return Err(symphonia::core::errors::Error::Unsupported( + "audio codec needs the compatibility decoder", + ) + .into()); + } + let mut opus = if is_opus { + Some(OpusPresentation::new(params, track.time_base)?) + } else { + None + }; + let gain = opus.as_ref().map_or(1.0, |presentation| presentation.gain); + let channels = params + .channels + .as_ref() + .context("audio channels missing")? + .count(); + ensure!( + (1..=255).contains(&channels), + "audio channels exceed safety limit" + ); + let mut decoder = PacketDecoder::new(params, channels)?; + let mut samples = Vec::new(); + let mut frames = 0_u64; + loop { + budget.check()?; + read_budget.start_packet(); + let Some(packet) = format.next_packet().context("read audio packet")? else { + break; + }; + if packet.track_id != track_id { + continue; + } + decoder.decode(&packet, channels, &mut samples)?; + let presentation = if let Some(opus) = &mut opus { + opus.samples(&packet, &samples, channels)? + } else { + samples.as_slice() + }; + // Mean absolute channel amplitude avoids cancelling out-of-phase stereo. + for frame in presentation.chunks_exact(channels) { + let divisor = f32::from(u16::try_from(channels)?); + let amplitude = frame + .iter() + .map(|sample| (sample * gain).abs().min(1.0)) + .sum::() + / divisor; + visit(amplitude)?; + frames = frames + .checked_add(1) + .context("audio sample count overflow")?; + } + } + ensure!(frames > 0, "audio contains no decodable samples"); + budget.check()?; + Ok(frames) +} + +/// Render an audio waveform with exact bucket boundaries and bounded memory. +/// +/// A first streaming pass measures decoded duration; a second fills width-sized +/// peak buckets. This also handles containers without reliable duration metadata. +/// +/// # Errors +/// Returns an error for malformed/unsupported audio, resource limits, cancellation, +/// or output I/O failures. Call on a blocking worker, not an async executor. +pub fn render_waveform( + input: &Path, + output: &Path, + width: u32, + height: u32, + cancel: &CancellationToken, +) -> Result<()> { + super::images::validate_dimensions(width, height)?; + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(crate::config::ffmpeg_timeout_secs()), + cancel, + }; + let total = visit_samples(input, &budget, |_| Ok(()))?; + let mut peaks = vec![0.0_f32; usize::try_from(width)?]; + let mut position = 0_u64; + let actual = visit_samples(input, &budget, |amplitude| { + let bucket = u128::from(position) * u128::from(width) / u128::from(total); + let index = usize::try_from(bucket.min(u128::from(width - 1)))?; + let peak = peaks + .get_mut(index) + .context("waveform bucket out of range")?; + *peak = peak.max(amplitude); + position = position + .checked_add(1) + .context("waveform position overflow")?; + Ok(()) + })?; + ensure!(actual == total, "audio changed between waveform passes"); + let pixels = render_peaks(&peaks, width, height)?; + budget.check()?; + pixels + .save_with_format(output, image::ImageFormat::Png) + .context("write waveform PNG") +} + +/// Draw a symmetric gray amplitude envelope on a transparent canvas. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::as_conversions, + reason = "clamped normalized amplitudes map to a bounded pixel radius" +)] +fn render_peaks(peaks: &[f32], width: u32, height: u32) -> Result { + let mut image = RgbaImage::new(width, height); + let middle = height / 2; + let max_radius = middle.saturating_sub(1); + let radius_scale = f32::from(u16::try_from(max_radius).context("waveform is too tall")?); + for (column, peak) in peaks.iter().enumerate() { + let radius = (peak.clamp(0.0, 1.0) * radius_scale).round() as u32; + let x = u32::try_from(column)?; + for y in middle.saturating_sub(radius)..=middle.saturating_add(radius).min(height - 1) { + image.put_pixel(x, y, Rgba([0x88, 0x88, 0x88, 255])); + } + } + Ok(image) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_errors_exclude_malformed_packets_io_and_resource_failures() { + assert!(is_unsupported( + &symphonia::core::errors::Error::Unsupported( + "audio codec needs the compatibility decoder" + ) + .into() + )); + for error in [ + anyhow::Error::from(opus_decoder::OpusError::InternalError), + anyhow::Error::from(opus_decoder::OpusError::InvalidPacket), + anyhow::Error::from(opus_decoder::OpusError::BufferTooSmall), + anyhow::Error::from(symphonia::core::errors::Error::DecodeError( + "invalid header", + )), + anyhow::Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "deadline", + )), + anyhow::anyhow!("audio packet exceeds sample budget"), + ] { + assert!(!is_unsupported(&error)); + } + for message in [ + "core (probe): no suitable format reader found", + "isomp4: missing ftyp atom", + "isomp4: missing moov atom", + "mkv: missing segment element", + "mkv: video width too large", + "mkv: timestamp scale too large (report this)", + "ogg: page is not marked as first", + "aac: sample rate is required", + "aac: coupling channel element", + "aac: program config", + "aac: predictor data", + "aac: gain control data", + "adts: only 1 aac frame per adts packet is supported", + ] { + assert!( + !is_unsupported(&symphonia::core::errors::Error::Unsupported(message).into()), + "invalid/resource error selected fallback: {message}" + ); + } + } + + #[test] + fn all_supported_audio_fixtures_produce_deterministic_visible_waveforms() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let dir = tempfile::tempdir()?; + let first = dir.path().join("first.png"); + let second = dir.path().join("second.png"); + for file in [ + "tone.wav", + "tone.mp3", + "tone.flac", + "tone.ogg", + "tone.opus", + "speech-mode.opus", + "tone.aac", + "tone.m4a", + "tone-alac.m4a", + "audio.webm", + "audio.mkv", + ] { + render_waveform( + &root.join(file), + &first, + 250, + 125, + &CancellationToken::new(), + ) + .with_context(|| format!("waveform for {file}"))?; + render_waveform( + &root.join(file), + &second, + 250, + 125, + &CancellationToken::new(), + )?; + ensure!( + std::fs::read(&first)? == std::fs::read(&second)?, + "{file} waveform is not deterministic" + ); + let pixels = image::open(&first)?.into_rgba8(); + ensure!( + pixels.dimensions() == (250, 125), + "waveform dimensions changed" + ); + ensure!( + pixels + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count() + > 250, + "{file} waveform looks silent" + ); + } + Ok(()) + } + + /// Build valid PCM without any executable or codec fixture generator. + fn write_pcm(path: &Path, channels: u16, frames: u32, silence: bool) -> Result<()> { + use std::io::Write as _; + let data_len = frames + .checked_mul(u32::from(channels) * 2) + .context("PCM length")?; + let mut file = std::fs::File::create(path)?; + file.write_all(b"RIFF")?; + file.write_all(&(data_len + 36).to_le_bytes())?; + file.write_all(b"WAVEfmt ")?; + file.write_all(&16_u32.to_le_bytes())?; + file.write_all(&1_u16.to_le_bytes())?; + file.write_all(&channels.to_le_bytes())?; + file.write_all(&8000_u32.to_le_bytes())?; + file.write_all(&(8000 * u32::from(channels) * 2).to_le_bytes())?; + file.write_all(&(channels * 2).to_le_bytes())?; + file.write_all(&16_u16.to_le_bytes())?; + file.write_all(b"data")?; + file.write_all(&data_len.to_le_bytes())?; + let mut samples = Vec::with_capacity(usize::try_from(data_len)?); + for frame in 0..frames { + for channel in 0..channels { + let sample = if silence { + 0_i16 + } else if (frame + u32::from(channel)) % 2 == 0 { + 16_000_i16 + } else { + -16_000_i16 + }; + samples.extend_from_slice(&sample.to_le_bytes()); + } + } + file.write_all(&samples)?; + Ok(()) + } + + #[test] + fn actual_silence_single_frame_surround_and_long_pcm_are_streamed() -> Result<()> { + let dir = tempfile::tempdir()?; + let input = dir.path().join("sound.wav"); + let output = dir.path().join("wave.png"); + for (channels, frames, silence) in [ + (1, 1, false), + (6, 800, false), + (2, 480, true), + (1, 960_000, true), + ] { + write_pcm(&input, channels, frames, silence)?; + render_waveform(&input, &output, 250, 125, &CancellationToken::new())?; + let pixels = image::open(&output)?.into_rgba8(); + let ink = pixels + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count(); + ensure!( + pixels.dimensions() == (250, 125), + "PCM waveform dimensions changed" + ); + if silence { + ensure!(ink == 250, "silence should be a flat line"); + } else { + ensure!(ink > 250, "opposite-phase/single-frame audio disappeared"); + } + } + Ok(()) + } + + #[test] + fn opus_encoder_delay_and_end_padding_do_not_extend_the_waveform() -> Result<()> { + let input = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media/tone.opus"); + let cancel = CancellationToken::new(); + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(30), + cancel: &cancel, + }; + ensure!( + visit_samples(&input, &budget, |_| Ok(()))? == 5760, + "120ms Opus clip includes encoder delay/padding" + ); + Ok(()) + } + + #[test] + fn silence_malformed_and_cancellation_are_safe() -> Result<()> { + let image = render_peaks(&[0.0, 0.0, 0.0], 3, 2)?; + ensure!( + image + .pixels() + .filter(|pixel| pixel.0.get(3) == Some(&255)) + .count() + == 3, + "silence should be a flat center line" + ); + let dir = tempfile::tempdir()?; + let source = dir.path().join("bad.wav"); + let output = dir.path().join("wave.png"); + std::fs::write(&source, b"RIFF\xff\xff\xff\xffWAVE")?; + ensure!( + render_waveform(&source, &output, 250, 125, &CancellationToken::new()).is_err(), + "malformed audio decoded" + ); + ensure!(!output.exists(), "malformed audio published output"); + let cancel = CancellationToken::new(); + cancel.cancel(); + ensure!( + render_waveform(&source, &output, 250, 125, &cancel).is_err(), + "cancelled decoding continued" + ); + Ok(()) + } + + #[test] + fn malformed_container_errors_cannot_select_compatibility() -> Result<()> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("bad.m4a"); + let output = dir.path().join("wave.png"); + for bytes in [ + b"not an audio container".as_slice(), + b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", + b"OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", + b"\x1a\x45\xdf\xa3\x01", + ] { + std::fs::write(&source, bytes)?; + let error = render_waveform(&source, &output, 250, 125, &CancellationToken::new()) + .err() + .context("malformed audio was decoded")?; + ensure!( + !is_unsupported(&error), + "malformed input selected fallback: {error:#}" + ); + ensure!(!output.exists(), "malformed input published a waveform"); + } + Ok(()) + } + + #[test] + fn implicit_he_aac_is_unsupported_and_truncated_extensions_are_invalid() -> Result<()> { + ensure!( + aac_needs_compatibility(&[0x13, 0x10, 0x56, 0xe5, 0x98])?, + "implicit SBR was ignored" + ); + ensure!( + !aac_needs_compatibility(&[0x11, 0x90])?, + "AAC-LC selected compatibility" + ); + let error = aac_needs_compatibility(&[0x13, 0x10, 0x56, 0xe5]) + .err() + .context("truncated SBR configuration was accepted")?; + ensure!(!is_unsupported(&error), "truncated SBR selected fallback"); + for bytes in [&[0x28][..], &[0x00, 0x90], &[0x17, 0x10]] { + let error = aac_needs_compatibility(bytes) + .err() + .context("invalid AAC config accepted")?; + ensure!( + !is_unsupported(&error), + "invalid AAC config selected fallback" + ); + } + Ok(()) + } +} diff --git a/src/media/convert.rs b/src/media/convert.rs index 040c5eb2..dcab1452 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -2,8 +2,8 @@ // Per-format conversion logic. // // Conversion rules (from project spec): -// jpg / jpeg → WebP (quality 85, metadata stripped) -// gif → WebP (quality 85, -loop 0 preserves animation if libwebp supports it) +// jpg / jpeg → WebP (lossless, metadata stripped) +// gif → animated WebP (composited frames, timing and repeats preserved) // heic/heif → WebP // bmp → WebP // tiff → WebP @@ -14,8 +14,7 @@ // all audio → keep as-is // mp4 → keep as-is (background worker handles MP4→WebM separately) // -// All conversion functions require ffmpeg. Callers must check -// `MediaProcessor::ffmpeg_available` before calling into this module. +// Image decoding and WebP encoding run internally in Rust. // On failure, all functions log a warning and the caller falls back to // storing the original bytes. @@ -23,7 +22,7 @@ use anyhow::{Context as _, Result}; use std::path::{Path, PathBuf}; use uuid::Uuid; -use super::ffmpeg; +use super::images; /// Describes what action the conversion pipeline should take for a given /// source MIME type. @@ -44,10 +43,6 @@ pub enum ConversionAction { #[must_use] pub fn conversion_action(mime: &str) -> ConversionAction { match mime { - // GIF → animated WebP: keeps the media type as Image so it renders in - // an tag rather than a
+ {settings_search}
{section_index} diff --git a/src/templates/admin/network.rs b/src/templates/admin/network.rs index 5a617594..1010e446 100644 --- a/src/templates/admin/network.rs +++ b/src/templates/admin/network.rs @@ -42,11 +42,11 @@ pub(super) fn render_section( r#"

{label}

{description}

{capabilities} -

Save writes settings.toml atomically. These settings require a restart; running listeners and workers keep their active values. Environment and launcher overrides take precedence on restart.

+

Save writes settings.toml atomically. Controls marked restart required use process initialization; running listeners and workers keep their active values. Environment and launcher overrides take precedence on restart.

{controls} -
Review saved values and overrides before restarting your service.
+
Review saved values, then use Restart RustChan in Configuration restart.
"#, label = escape_html(label), description = escape_html(description), @@ -175,7 +175,9 @@ fn render_field(field: &SettingField) -> String { option("reads", "Reads: GET and HEAD", &field.input) ), }; - let state = if field.overridden { + let state = if definition.application == crate::config::admin::ApplicationMode::Live { + "Applies live through its existing save control" + } else if field.overridden { "Override prevents the saved value taking effect; manage the environment or launcher" } else if field.pending { "Saved configuration differs — restart required; check overrides" @@ -184,7 +186,7 @@ fn render_field(field: &SettingField) -> String { }; format!( r#"
-{control} +{application}{control}

{}

Active
{}
Saved
{}
Next restart
{}
Source
{}
Application
{state}
"#, @@ -196,7 +198,12 @@ fn render_field(field: &SettingField) -> String { escape_html(&field.active), escape_html(&field.saved), escape_html(&field.next_start), - escape_html(&field.source) + escape_html(&field.source), + application = if definition.application == crate::config::admin::ApplicationMode::Restart { + "Restart required" + } else { + "Applies live" + } ) } diff --git a/src/templates/admin/restart.rs b/src/templates/admin/restart.rs new file mode 100644 index 00000000..b9d9c01a --- /dev/null +++ b/src/templates/admin/restart.rs @@ -0,0 +1,22 @@ +//! Compact configuration restart status within the existing administrator panel. + +use crate::{restart::View, utils::sanitize::escape_html}; + +/// Preserve a fully usable POST form and refresh link with JavaScript disabled. +pub(super) fn render(view: &View, csrf: &str) -> String { + let action = if view.pending && view.supported && !view.in_progress { + format!( + r#"
Stops gracefully and verifies startup. Unsaved form edits are not included.
"#, + escape_html(csrf) + ) + } else { + String::new() + }; + format!( + r#"

Configuration restart

{}

{action}

Refresh restart status

"#, + view.instance, + view.pending, + view.in_progress, + escape_html(&view.message) + ) +} diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs index 51527f48..cd0f2bd4 100644 --- a/src/test_fixtures.rs +++ b/src/test_fixtures.rs @@ -77,6 +77,7 @@ pub(crate) fn app_state() -> crate::middleware::AppState { } let job_queue = std::sync::Arc::new(crate::workers::JobQueue::new(pool.clone())); crate::middleware::AppState { + runtime_ready: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), db: pool, ffmpeg_available: false, ffmpeg_webp_available: false, diff --git a/src/updates/daemon.rs b/src/updates/daemon.rs index 33e0469f..d20fcdf0 100644 --- a/src/updates/daemon.rs +++ b/src/updates/daemon.rs @@ -49,6 +49,8 @@ mod linux { starting: AtomicBool, /// Tracks the installation worker lifetime. installing: AtomicBool, + /// Includes restart preflight and lost acknowledgments, before phase publication. + restarting: AtomicBool, } impl Admission { /// Authorize startup only in a phase that has safe persistent state. @@ -111,6 +113,7 @@ mod linux { /// Run the separate operator-configured Linux updater daemon. pub(super) async fn run(config_path: &Path) -> anyhow::Result<()> { let engine = Arc::new(configured_engine(config_path)?); + crate::config::configure_data_dir(Some(&engine.config.data_dir))?; // Exclusive daemon lock prevents two listeners/recovery workers. File // locks release on death, unlike pid files or lock directories. let daemon_lock = fs::File::options() @@ -217,6 +220,7 @@ mod linux { let result = match request { Request::Status => engine.status().map(|status| { let writable = admission.recovered.load(Ordering::Acquire) + && !admission.restarting.load(Ordering::Acquire) && !status.phase.blocks_writes() && status.phase != super::super::Phase::FailedManualIntervention; (status, writable) @@ -228,6 +232,22 @@ mod linux { .is_ok_and(|current| current == version); (status, ready) }), + Request::Started { + instance, + configuration, + } => { + anyhow::ensure!( + admission.may_start(engine.status()?.phase), + "startup admission is closed" + ); + engine + .started(instance, &configuration) + .map(|status| (status, true)) + } + Request::Restart { + instance, + administrator, + } => launch_restart(engine, admission, instance, administrator), Request::Check => { anyhow::ensure!( admission.recovered.load(Ordering::Acquire), @@ -280,11 +300,78 @@ mod linux { }), Err(error) => { tracing::warn!(error = %error, "updater operation failed"); - Ok(Reply { status: engine.status()?, error: Some("Update operation could not proceed. Another operation may be running, or preflight/verification failed. Check updater logs.".into()), ready: false }) + Ok(Reply { status: engine.status()?, error: Some("Operation could not proceed. Another operation may be running, or preflight/verification failed. Check updater logs.".into()), ready: false }) } } } + /// Transfer both transaction leases to one settings restart worker. + fn launch_restart( + engine: &Arc, + admission: &Arc, + instance: uuid::Uuid, + administrator: i64, + ) -> anyhow::Result<(super::super::Status, bool)> { + anyhow::ensure!( + admission + .restarting + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "restart already in progress" + ); + let activity = RestartActivity(Arc::clone(admission)); + anyhow::ensure!( + admission.recovered.load(Ordering::Acquire), + "recovery is incomplete" + ); + let service = SystemService { + port: engine.config.health_port, + admission: Arc::clone(admission), + }; + let (status, update_lock, settings_lock) = + engine.approve_restart(instance, administrator, &service)?; + let mut worker_status = status.clone(); + let worker_engine = Arc::clone(engine); + let worker_admission = Arc::clone(admission); + let spawned = std::thread::Builder::new() + .name("rustchan-settings-restart".into()) + .spawn(move || { + let _activity = activity; + let _update_lock = update_lock; + let _settings_lock = settings_lock; + let service = SystemService { + port: worker_engine.config.health_port, + admission: Arc::clone(&worker_admission), + }; + // Let the acknowledgment and no-JS redirect finish before service stop. + std::thread::sleep(Duration::from_secs(1)); + let result = worker_engine.restart_settings(&mut worker_status, &service); + if let Err(error) = result { + worker_admission.recovered.store(false, Ordering::Release); + tracing::error!(%error, "settings restart journal failure; recovery required"); + } + }); + if let Err(error) = spawned { + let mut failed = status; + failed.restart_instance = None; + engine.save( + &mut failed, + super::super::Phase::Failed, + "Restart worker could not start; running configuration retained.", + )?; + return Err(error.into()); + } + Ok((status, false)) + } + + /// Close mutation admission from restart preflight through the worker's complete result. + struct RestartActivity(Arc); + impl Drop for RestartActivity { + fn drop(&mut self) { + self.0.restarting.store(false, Ordering::Release); + } + } + #[derive(Debug, serde::Deserialize)] /// Minimal public readiness fields used for version validation. struct ReadyHealth { @@ -367,6 +454,15 @@ mod linux { control("start") } fn health(&self, version: &str, _schema: &str) -> anyhow::Result<()> { + self.probe_health(version, None) + } + fn health_instance(&self, version: &str, previous: uuid::Uuid) -> anyhow::Result<()> { + self.probe_health(version, Some(previous)) + } + } + impl SystemService { + /// Probe the fixed loopback endpoint with bounded time, size and generation checks. + fn probe_health(&self, version: &str, previous: Option) -> anyhow::Result<()> { struct Reset<'a>(&'a AtomicBool); impl Drop for Reset<'_> { fn drop(&mut self) { @@ -386,12 +482,21 @@ mod linux { .get(format!("http://127.0.0.1:{}/readyz", self.port)) .send()? .error_for_status()?; + let observed_instance = response + .headers() + .get("x-rustchan-instance") + .and_then(|value| value.to_str().ok()) + .and_then(|value| uuid::Uuid::parse_str(value).ok()); let mut bytes = Vec::new(); response.take(4097).read_to_end(&mut bytes)?; anyhow::ensure!(bytes.len() <= 4096, "readiness response exceeds limit"); let health: ReadyHealth = serde_json::from_slice(&bytes)?; anyhow::ensure!( - health.version == version && health.status == "ready", + health.version == version + && health.status == "ready" + && previous.is_none_or(|old| { + observed_instance.is_some_and(|instance| instance != old) + }), "RustChan health or version mismatch" ); Ok::<_, anyhow::Error>(()) diff --git a/src/updates/mod.rs b/src/updates/mod.rs index 8994a65f..39e264e2 100644 --- a/src/updates/mod.rs +++ b/src/updates/mod.rs @@ -6,12 +6,15 @@ mod daemon; /// Official stable release discovery and cryptographic verification. mod release; #[cfg(unix)] +/// Configuration transactions sharing the update control boundary. +mod restart; +#[cfg(unix)] mod snapshot; mod transaction; pub use daemon::run; pub use release::{discover, platform_target, Discovery, Release}; -pub use transaction::{BackupInfo, Phase, Status}; +pub use transaction::{BackupInfo, Operation, Phase, Status}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -36,6 +39,20 @@ pub enum Request { /// Authenticated full administrator requesting installation. administrator: i64, }, + /// Record a fully initialized process bound to its exact loaded settings. + Started { + /// Fresh process identity; replay cannot overwrite a restart's generation. + instance: uuid::Uuid, + /// Private SHA-256 binding of startup settings, not exposed in readiness. + configuration: String, + }, + /// Request only the fixed `RustChan` settings restart, once per running instance. + Restart { + /// Last ready process identity, issued by the application, never browser-selected. + instance: uuid::Uuid, + /// Authenticated full administrator. + administrator: i64, + }, /// Authorize startup only after updater recovery and current-version validation. Ready { /// Running package version requesting startup admission. @@ -175,6 +192,13 @@ mod tests { .is_err(), "arbitrary commands must be rejected" ); + for extra in ["command", "arguments", "service", "executable", "path"] { + let value = serde_json::json!({"operation":"restart", "instance": uuid::Uuid::new_v4(), "administrator":1, extra: "anything"}); + anyhow::ensure!( + serde_json::from_value::(value).is_err(), + "restart must reject arbitrary {extra}" + ); + } let dir = tempfile::tempdir()?; anyhow::ensure!( !mutations_allowed(Some(&dir.path().join("missing.sock"))).await, diff --git a/src/updates/restart.rs b/src/updates/restart.rs new file mode 100644 index 00000000..de6b456c --- /dev/null +++ b/src/updates/restart.rs @@ -0,0 +1,199 @@ +//! Configuration-only transactions inside the existing updater engine. + +use super::{ + transaction::native::{Engine, Service}, + Operation, Phase, Status, +}; +use crate::restart::{configuration_digest, Store}; +use anyhow::Context as _; +use std::fs::File; +use uuid::Uuid; + +impl Engine { + /// The updater exclusively owns rollback payloads; the web identity cannot forge them. + pub(super) fn restart_store(&self) -> Store { + Store { + directory: self.config.state_dir.join("settings-restart"), + settings: self.config.settings_path.clone(), + } + } + /// Record actual startup without replacing rollback bytes during an active transaction. + pub(super) fn started(&self, instance: Uuid, configuration: &str) -> anyhow::Result { + anyhow::ensure!( + configuration.len() == 64 && configuration.bytes().all(|c| c.is_ascii_hexdigit()), + "invalid configuration binding" + ); + let status = self.status()?; + let store = self.restart_store(); + if status.phase.active() { + anyhow::ensure!( + status.restart_instance != Some(instance), + "old process cannot verify replacement initialization" + ); + store.observe(instance, configuration, false)?; + } else { + let _update = self.lock()?; + let _settings = crate::config::admin::settings_lease(&self.config.settings_path)?; + store.observe(instance, configuration, true)?; + } + Ok(status) + } + /// Accept a single fixed restart only after config/service preflight and durable journal intent. + /// Both leases transfer to the worker and remain held through health or rollback. + pub(super) fn approve_restart( + &self, + instance: Uuid, + administrator: i64, + service: &impl Service, + ) -> anyhow::Result<(Status, File, File)> { + let update = self.lock()?; + let settings = crate::config::admin::settings_lease(&self.config.settings_path)?; + let mut status = self.status()?; + anyhow::ensure!( + !status.phase.active() && status.phase != Phase::FailedManualIntervention, + "another transaction or recovery is in progress" + ); + anyhow::ensure!( + status.restart_instance != Some(instance), + "restart for this process was already consumed" + ); + let store = self.restart_store(); + let running = store.running()?; + anyhow::ensure!( + running.instance == instance && administrator > 0, + "stale process or invalid administrator" + ); + let candidate = store.candidate()?; + anyhow::ensure!( + configuration_digest(&candidate) != running.digest, + "configuration has not changed" + ); + let good = Store::read(&store.directory.join("known-good.toml"), 4 * 1024 * 1024)?; + anyhow::ensure!( + crate::config::admin::restart_files_differ( + std::str::from_utf8(&good)?, + std::str::from_utf8(&candidate)? + )?, + "no startup settings changed" + ); + crate::config::validate_update_settings( + std::str::from_utf8(&good)?, + &self.config.data_dir, + )?; + service.preflight()?; + store.write( + "candidate.sha256", + configuration_digest(&candidate).as_bytes(), + )?; + status.operation = Operation::SettingsRestart; + status.restart_instance = Some(instance); + status.approval = None; + status.job = Some(Uuid::new_v4().to_string()); + status.administrator = Some(administrator); + status.installed = self.current_version()?; + status.previous_version = Some(status.installed.clone()); + status.target_version.clone_from(&status.previous_version); + self.save( + &mut status, + Phase::Stopping, + "Settings restart accepted. RustChan is draining requests.", + )?; + Ok((status, update, settings)) + } + /// Stop through systemd SIGTERM, start the fixed service, then verify exact loaded config. + pub(super) fn restart_settings( + &self, + status: &mut Status, + service: &impl Service, + ) -> anyhow::Result<()> { + if let Err(error) = service.stop() { + // An unconfirmed stop must never permit configuration restoration under a live process. + tracing::error!(%error, "settings shutdown did not confirm service stop"); + self.save(status, Phase::FailedManualIntervention, "RustChan did not stop cleanly. Configuration was not replaced; operator recovery is required.")?; + return Ok(()); + } + let trial = (|| { + self.save( + status, + Phase::Restarting, + "Starting RustChan with saved settings.", + )?; + service.start()?; + self.save( + status, + Phase::HealthChecking, + "Verifying replacement process, database and listener readiness.", + )?; + let previous = status + .restart_instance + .context("missing previous process identity")?; + service.health_instance(&status.installed, previous)?; + let store = self.restart_store(); + let running = store.running()?; + let expected = Store::read(&store.directory.join("candidate.sha256"), 64)?; + anyhow::ensure!( + running.instance != previous && running.digest.as_bytes() == expected, + "replacement did not initialize the requested settings" + ); + self.save( + status, + Phase::Succeeded, + "RustChan restarted; saved settings passed readiness verification.", + )?; + store.observe(running.instance, &running.digest, true) + })(); + if let Err(error) = trial { + tracing::error!(%error, "settings restart failed; recovering known-good configuration"); + self.recover_settings_restart(status, service)?; + } + Ok(()) + } + /// Configuration rollback reuses updater recovery admission without restoring any database. + pub(super) fn recover_settings_restart( + &self, + status: &mut Status, + service: &impl Service, + ) -> anyhow::Result<()> { + let recovery = (|| { + self.save( + status, + Phase::RollingBack, + "Settings startup failed or was interrupted; restoring previous configuration.", + )?; + service.stop()?; + self.restart_store().restore()?; + self.save( + status, + Phase::RestartingPrevious, + "Checking RustChan with the last healthy configuration.", + )?; + service.start()?; + service.health_instance( + &status.installed, + status + .restart_instance + .context("missing restart identity")?, + )?; + let store = self.restart_store(); + let running = store.running()?; + let expected = configuration_digest(&Store::read( + &store.directory.join("known-good.toml"), + 4 * 1024 * 1024, + )?); + anyhow::ensure!( + running.digest == expected, + "recovery configuration did not initialize" + ); + self.save( + status, + Phase::RolledBack, + "Settings startup failed. Previous configuration restored and readiness verified.", + ) + })(); + if let Err(error) = recovery { + tracing::error!(%error, "settings recovery failed"); + self.save(status, Phase::FailedManualIntervention, "Settings restart and recovery failed. Last healthy configuration is retained; operator recovery is required.")?; + } + Ok(()) + } +} diff --git a/src/updates/transaction.rs b/src/updates/transaction.rs index 8f830a79..dfee3615 100644 --- a/src/updates/transaction.rs +++ b/src/updates/transaction.rs @@ -98,6 +98,28 @@ impl Phase { } } +/// Operation sharing the updater's lock, journal and recovery admission. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + /// Signed software installation with complete persistent-state rollback. + #[default] + Update, + /// Configuration-only restart; database/media must never be rolled back. + SettingsRestart, +} + +impl Operation { + /// Retain wire compatibility with earlier update-only status schemas. + #[expect( + clippy::trivially_copy_pass_by_ref, + reason = "Serde skip_serializing_if callbacks require a shared reference" + )] + const fn is_update(&self) -> bool { + matches!(self, Self::Update) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] /// Verified updater-owned pre-upgrade snapshot metadata exposed to administrators. @@ -133,6 +155,12 @@ pub struct BackupInfo { #[serde(deny_unknown_fields)] /// Durable administrator-only discovery, transaction and retained snapshot state. pub struct Status { + /// Kind of transaction; old update journals default to software installation. + #[serde(default, skip_serializing_if = "Operation::is_update")] + pub operation: Operation, + /// Process identity whose single settings restart was accepted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub restart_instance: Option, /// Durable installation or recovery phase. pub phase: Phase, /// Currently selected immutable version. @@ -250,6 +278,11 @@ pub(super) mod native { fn start(&self) -> anyhow::Result<()>; /// Bound readiness and require the expected running application version. fn health(&self, version: &str, schema: &str) -> anyhow::Result<()>; + /// Verify replacement identity in addition to database/listener readiness. + fn health_instance(&self, version: &str, previous: Uuid) -> anyhow::Result<()> { + let _ = previous; + self.health(version, crate::db::baseline_schema_version()) + } } /// Publish exact bytes via same-filesystem rename after file and directory fsync. @@ -553,6 +586,8 @@ pub(super) mod native { ); status.previous_version = Some(current); status.target_version = Some(manifest.version.clone()); + status.operation = super::Operation::Update; + status.restart_instance = None; status.administrator = Some(administrator); status.job = Some(Uuid::new_v4().to_string()); status.approval = None; @@ -581,10 +616,14 @@ pub(super) mod native { service: &impl Service, source: &impl ArtifactSource, ) -> anyhow::Result<()> { + let settings = crate::config::admin::settings_lease(&self.config.settings_path); let coordination = self.coordination_lock(); - let outcome = match &coordination { - Ok(_) => self.install_inner(status, manifest, service, source), - Err(error) => Err(anyhow::anyhow!("backup coordination failed: {error}")), + let outcome = match (&settings, &coordination) { + (Ok(_), Ok(_)) => self.install_inner(status, manifest, service, source), + (Err(error), _) => Err(anyhow::anyhow!( + "configuration coordination failed: {error}" + )), + (_, Err(error)) => Err(anyhow::anyhow!("backup coordination failed: {error}")), }; if let Err(error) = outcome { tracing::error!(error = %error, "update failed"); @@ -717,6 +756,11 @@ pub(super) mod native { Phase::Succeeded, "RustChan updated successfully after health verification.", )?; + let store = self.restart_store(); + if store.directory.join("running.json").try_exists()? { + let running = store.running()?; + store.observe(running.instance, &running.digest, true)?; + } self.retain_terminal(status); Ok(()) } @@ -1007,6 +1051,10 @@ pub(super) mod native { pub(in crate::updates) fn recover(&self, service: &impl Service) -> anyhow::Result<()> { let _lock = self.lock()?; let mut status = self.status()?; + if status.operation == super::Operation::SettingsRestart && status.phase.active() { + let _settings = crate::config::admin::settings_lease(&self.config.settings_path)?; + return self.recover_settings_restart(&mut status, service); + } self.reconcile_backups(&mut status)?; if !status.phase.active() { self.retain_terminal(&mut status); diff --git a/src/updates/transaction_tests.rs b/src/updates/transaction_tests.rs index 11eb984e..2226d80b 100644 --- a/src/updates/transaction_tests.rs +++ b/src/updates/transaction_tests.rs @@ -583,3 +583,164 @@ fn private_or_hardlinked_current_binary_is_rejected() -> anyhow::Result<()> { ); Ok(()) } + +/// Configuration-only supervisor mock, with no actual service execution. +struct RestartService<'a> { + /// Disposable deployment under test. + engine: &'a Engine, + /// Number of replacement starts; the second is rollback recovery. + starts: Cell, + /// Inject a failure in the first startup rather than launching host processes. + fail_start: bool, + /// Fail the trial health check, preserving all database writes. + fail_health: bool, + /// Simulate the old process falsely answering health after service control. + old_instance: Option, +} +impl Service for RestartService<'_> { + fn stop(&self) -> anyhow::Result<()> { Ok(()) } + fn start(&self) -> anyhow::Result<()> { + self.starts.set(self.starts.get() + 1); + if self.starts.get() == 1 && self.fail_start { anyhow::bail!("injected settings startup failure"); } + if self.starts.get() == 1 && self.old_instance.is_some() { return Ok(()); } + let bytes = crate::restart::Store::read(&self.engine.config.settings_path, 4 * 1024 * 1024)?; + self.engine.started(Uuid::new_v4(), &crate::restart::configuration_digest(&bytes))?; + Ok(()) + } + fn health(&self, _version: &str, _schema: &str) -> anyhow::Result<()> { + anyhow::ensure!(!(self.starts.get() == 1 && self.fail_health), "injected readiness timeout"); + Ok(()) + } + fn health_instance(&self, version: &str, previous: Uuid) -> anyhow::Result<()> { + self.health(version, "")?; + anyhow::ensure!(self.engine.restart_store().running()?.instance != previous, "old process cannot satisfy readiness"); + Ok(()) + } +} + +/// Record a healthy initial process and a validated pending startup policy change. +fn pending_restart(engine: &Engine) -> anyhow::Result { + let instance = Uuid::new_v4(); + let good = format!("enable_tor_support = false\ncookie_secret = '{}'\n", "ab".repeat(32)).into_bytes(); + fs::write(&engine.config.settings_path, &good)?; + engine.started(instance, &crate::restart::configuration_digest(&good))?; + let candidate = format!("{}\nrate_limit_gets = 12345\n", std::str::from_utf8(&good)?); + crate::config::admin::atomic_replace(&engine.config.settings_path, &candidate)?; + Ok(instance) +} + +/// Pending settings survive until replacement readiness; both OS leases exclude all races. +#[test] +fn settings_restart_health_commit_and_update_mutual_exclusion() -> anyhow::Result<()> { + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let service = RestartService { engine, starts: Cell::new(0), fail_start: false, fail_health: false, old_instance: None }; + let (mut status, update, settings) = engine.approve_restart(previous, 1, &service)?; + anyhow::ensure!(status.phase.blocks_writes() && status.phase.active(), "restart must close write admission"); + anyhow::ensure!(engine.lock().is_err(), "software installation must be serialized with restart"); + anyhow::ensure!(engine.approve_restart(previous, 2, &service).is_err(), "duplicate/concurrent restart must reject"); + anyhow::ensure!(crate::config::admin::settings_lease(&engine.config.settings_path).is_err(), "saves must not race a restart"); + anyhow::ensure!(engine.restart_store().running()?.instance == previous, "pending cannot clear merely on request acceptance"); + engine.restart_settings(&mut status, &service)?; + anyhow::ensure!(status.phase == Phase::Succeeded, "healthy replacement must commit"); + anyhow::ensure!(engine.restart_store().running()?.instance != previous, "successful restart must observe a replacement"); + drop(settings); drop(update); + anyhow::ensure!(engine.approve_restart(previous, 1, &service).is_err(), "replayed process approval must remain consumed"); + Ok(()) +} + +/// Startup, health timeout and stale process readiness all restore config without reverting data. +#[test] +fn settings_restart_failures_recover_configuration_only() -> anyhow::Result<()> { + for (fail_start, fail_health, old_process) in [(true, false, false), (false, true, false), (false, false, true)] { + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let original = crate::restart::Store::read(&engine.restart_store().directory.join("known-good.toml"), 4 * 1024 * 1024)?; + fs::write(engine.config.data_dir.join("boards/pub/media.bin"), b"new content must survive")?; + let service = RestartService { engine, starts: Cell::new(0), fail_start, fail_health, old_instance: old_process.then_some(previous) }; + let (mut status, _update, _settings) = engine.approve_restart(previous, 1, &service)?; + engine.restart_settings(&mut status, &service)?; + anyhow::ensure!(status.phase == Phase::RolledBack && service.starts.get() == 2, "failed trial must verify previous config recovery"); + anyhow::ensure!(fs::read(&engine.config.settings_path)? == original, "known-good settings must be restored"); + anyhow::ensure!(fs::read(engine.config.data_dir.join("boards/pub/media.bin"))? == b"new content must survive", "configuration rollback must not revert media/database"); + } + Ok(()) +} + +/// An interrupted transaction enters the existing daemon recovery before startup admission. +#[test] +fn interrupted_settings_restart_reuses_updater_recovery() -> anyhow::Result<()> { + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let service = RestartService { engine, starts: Cell::new(1), fail_start: false, fail_health: false, old_instance: None }; + let (mut status, update, settings) = engine.approve_restart(previous, 1, &service)?; + engine.save(&mut status, Phase::HealthChecking, "interrupted settings startup")?; + drop(settings); drop(update); + engine.recover(&service)?; + anyhow::ensure!(engine.status()?.phase == Phase::RolledBack, "existing recovery must handle configuration-only transactions"); + Ok(()) +} + +/// Invalid candidate data never gets durable restart approval or touches a service. +#[test] +fn invalid_settings_cannot_start_restart_transaction() -> anyhow::Result<()> { + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let service = RestartService { engine, starts: Cell::new(0), fail_start: false, fail_health: false, old_instance: None }; + fs::write(&engine.config.settings_path, "port = 0\n")?; + anyhow::ensure!(engine.approve_restart(previous, 1, &service).is_err(), "invalid configuration must reject before service work"); + anyhow::ensure!(service.starts.get() == 0 && !engine.status()?.phase.active(), "invalid config must not start a restart"); + Ok(()) +} + +/// A timed-out stop must close admission without restoring beneath an unconfirmed process. +#[test] +fn failed_settings_shutdown_cannot_restore_under_an_unconfirmed_process() -> anyhow::Result<()> { + struct UnresponsiveService { stops: Cell, starts: Cell } + impl Service for UnresponsiveService { + fn stop(&self) -> anyhow::Result<()> { self.stops.set(self.stops.get() + 1); anyhow::bail!("shutdown timed out") } + fn start(&self) -> anyhow::Result<()> { self.starts.set(self.starts.get() + 1); anyhow::bail!("must not start after unconfirmed stop") } + fn health(&self, _version: &str, _schema: &str) -> anyhow::Result<()> { anyhow::bail!("must not check health after unconfirmed stop") } + } + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let candidate = fs::read(&engine.config.settings_path)?; + let service = UnresponsiveService { stops: Cell::new(0), starts: Cell::new(0) }; + let (mut status, _update, _settings) = engine.approve_restart(previous, 1, &service)?; + engine.restart_settings(&mut status, &service)?; + anyhow::ensure!(service.stops.get() == 1 && service.starts.get() == 0, "an unconfirmed stop must not trigger further service control"); + anyhow::ensure!(status.phase == Phase::FailedManualIntervention && fs::read(&engine.config.settings_path)? == candidate, "unconfirmed stop must retain configuration and close admission"); + anyhow::ensure!(engine.restart_store().running()?.instance == previous, "failed shutdown cannot verify a replacement"); + Ok(()) +} + +/// Two administrator requests compete for the same OS-owned transaction lease. +#[test] +fn concurrent_settings_restarts_accept_exactly_one_request() -> anyhow::Result<()> { + let fixture = fixture()?; + let engine = &fixture.engine; + let previous = pending_restart(engine)?; + let barrier = std::sync::Barrier::new(2); + let outcomes = std::thread::scope(|scope| { + let first = scope.spawn(|| { + let service = RestartService { engine, starts: Cell::new(0), fail_start: false, fail_health: false, old_instance: None }; + let result = engine.approve_restart(previous, 1, &service); + barrier.wait(); + result.is_ok() + }); + let second = scope.spawn(|| { + let service = RestartService { engine, starts: Cell::new(0), fail_start: false, fail_health: false, old_instance: None }; + let result = engine.approve_restart(previous, 2, &service); + barrier.wait(); + result.is_ok() + }); + Ok::<_, anyhow::Error>((first.join().map_err(|_| anyhow::anyhow!("first restart worker failed"))?, second.join().map_err(|_| anyhow::anyhow!("second restart worker failed"))?)) + })?; + anyhow::ensure!(outcomes.0 != outcomes.1 && engine.status()?.phase == Phase::Stopping, "exactly one concurrent restart may be durable"); + Ok(()) +} diff --git a/src/utils/fs_security.rs b/src/utils/fs_security.rs index a1d8e2c9..d839f98f 100644 --- a/src/utils/fs_security.rs +++ b/src/utils/fs_security.rs @@ -91,7 +91,7 @@ pub fn canonical_parent_for_new_child(root: &Path, path: &Path) -> Result Result<()> { +pub(crate) fn reject_symlink_components(path: &Path) -> Result<()> { let mut cursor = PathBuf::new(); for component in path.components() { cursor.push(component.as_os_str()); diff --git a/static/admin.js b/static/admin.js index cbb66e62..2446d1e9 100644 --- a/static/admin.js +++ b/static/admin.js @@ -2020,3 +2020,63 @@ document.addEventListener('DOMContentLoaded', function () { }); if (section.dataset.updateActive === 'true') reconnect(); }); + +// A restart has one bounded reconnect window; all status remains administrator-only. +(() => { + const section = document.getElementById('settings-restart'); + if (!section) return; + const form = document.getElementById('admin-restart-form'); + const status = document.getElementById('admin-restart-status'); + let deadline = 0; + let previousInstance = section.dataset.restartInstance; + let timer; + const poll = async () => { + if (Date.now() >= deadline) { + status.textContent = 'Restart verification timed out. Refresh restart status to review recovery or retry after the service returns.'; + return; + } + try { + const response = await fetch('/admin/restart/status', { cache: 'no-store', signal: AbortSignal.timeout(5000) }); + if (response.status === 403 || response.status === 401) { + status.textContent = 'RustChan reconnected. Sign in again to review restart status.'; + return; + } + if (response.ok) { + const result = await response.json(); + status.textContent = result.message; + if (!result.in_progress && result.instance !== previousInstance) { + location.reload(); + return; + } + if (!result.in_progress && !result.pending) return; + } + } catch (_) { status.textContent = 'RustChan is reconnecting. Waiting for verified readiness…'; } + timer = setTimeout(poll, 1500); + }; + if (section.dataset.restartInProgress === 'true') { + deadline = Date.now() + 240000; + poll(); + } + if (form) form.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = form.querySelector('button'); + if (button.disabled) return; + button.disabled = true; + previousInstance = section.dataset.restartInstance; + status.textContent = 'Requesting a graceful restart…'; + try { + const response = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, + body: new URLSearchParams(new FormData(form)), signal: AbortSignal.timeout(10000) }); + const result = await response.json().catch(() => null); + if (!response.ok) { + status.textContent = result?.message || 'Restart request rejected. Refresh restart status and review configuration before retrying.'; + button.disabled = false; + return; + } + status.textContent = result?.message || 'Restart accepted. Waiting for verified readiness…'; + } catch (_) { status.textContent = 'Restart acknowledgment was interrupted. Checking durable status…'; } + clearTimeout(timer); + deadline = Date.now() + 240000; + poll(); + }); +})(); diff --git a/tests/e2e/admin-runtime-settings.spec.ts b/tests/e2e/admin-runtime-settings.spec.ts index 6da606b3..2cbe39f0 100644 --- a/tests/e2e/admin-runtime-settings.spec.ts +++ b/tests/e2e/admin-runtime-settings.spec.ts @@ -10,7 +10,7 @@ test('Tor, media, schedules and system controls save supported settings with pen await adminLogin(page, app); const cases = [ { section: 'tor', field: 'tor_bootstrap_timeout_secs', value: '300', entries: 5 }, - { section: 'media', field: 'thumb_size', value: '512', entries: 17 }, + { section: 'media', field: 'thumb_size', value: '512', entries: 16 }, { section: 'schedules', field: 'db_warn_threshold_mb', value: '3000', entries: 4 }, { section: 'system', field: 'db_pool_size', value: '12', entries: 2 }, ]; diff --git a/tests/e2e/settings-restart.spec.ts b/tests/e2e/settings-restart.spec.ts new file mode 100644 index 00000000..057aee11 --- /dev/null +++ b/tests/e2e/settings-restart.spec.ts @@ -0,0 +1,156 @@ +import { test, expect, Page } from '@playwright/test'; +import { spawn, spawnSync, ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; + +// A fixed executable and a disposable data directory simulate Docker's restart policy. +// The HTTP process cannot select or spawn its replacement, and no host service is touched. +const binary = process.env.RUSTCHAN_E2E_BINARY || path.resolve('target/debug/rustchan-cli'); +let directory: string; +let baseURL: string; +let child: ChildProcess; +let stopped: boolean; +let starts: number; +let output: string; + +async function freePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as net.AddressInfo; + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +function launch() { + if (stopped) return; + starts += 1; + child = spawn(binary, ['--data-dir', directory, 'serve'], { + env: { ...process.env, CHAN_HOST: '127.0.0.1', CHAN_PORT: new URL(baseURL).port, + CHAN_TOR_SUPPORT: 'false', RUSTCHAN_CONTAINER: '1', RUSTCHAN_RESTART_ON_EXIT: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.on('data', (chunk) => { output += chunk.toString(); }); + child.stderr?.on('data', (chunk) => { output += chunk.toString(); }); + child.on('exit', () => { if (!stopped) setTimeout(launch, 100); }); +} + +async function healthy(page: Page) { + await expect.poll(async () => { + try { + const response = await page.request.get(`${baseURL}/readyz`, { timeout: 2000 }); + if (!response.ok()) return false; + // Public health may be reached during initialization; the private observation is final. + const login = await page.request.get(`${baseURL}/admin`, { timeout: 2000 }); + return login.ok() && fs.existsSync(path.join(directory, 'runtime/settings-restart/running.json')); + } catch { return false; } + }, { timeout: 50_000, message: `RustChan should reach readiness; logs: ${output}` }).toBe(true); +} + +async function login(page: Page) { + await page.goto(`${baseURL}/admin`); + await page.locator('input[name=username]').fill('admin'); + await page.locator('input[name=password]').fill('restart-test-password'); + await page.locator('form[action="/admin/login"] button[type=submit]').click(); + await expect(page.locator('#settings-restart')).toBeVisible(); +} + +async function saveNetwork(page: Page, overrides: Record) { + await page.goto(`${baseURL}/admin/panel?open=network-security#network-security`); + const form = page.locator('#admin-network-settings'); + const fields = await form.locator('input[name],select[name],textarea[name]').evaluateAll((controls) => + Object.fromEntries(controls.map((control) => [(control as HTMLInputElement).name, (control as HTMLInputElement).value]))); + const response = await page.request.post(`${baseURL}/admin/network/settings`, { + form: { ...fields, ...overrides }, headers: { origin: baseURL }, + }); + expect(response.status(), await response.text()).toBe(200); + await page.goto(`${baseURL}/admin/panel#settings-restart`); +} + +async function restartAndWait(page: Page, previous: string) { + // No-JS form navigation can overlap listener closure. Progress remains in the durable journal. + await page.locator('#admin-restart-form button').click({ noWaitAfter: true }); + await expect.poll(() => { + try { + const record = JSON.parse(fs.readFileSync(path.join(directory, 'runtime/settings-restart/running.json'), 'utf8')); + return record.instance !== previous; + } catch { return false; } + }, { timeout: 75_000 }).toBe(true); + await healthy(page); + if (test.info().project.use.javaScriptEnabled !== false) { + // The enhanced UI must reconnect itself, without a test-driven navigation. + await expect(page.locator('#settings-restart')).not.toHaveAttribute('data-restart-instance', previous); + } else { + await page.goto(`${baseURL}/admin/panel#settings-restart`); + await page.reload(); + } +} + +test.beforeEach(async ({ page }) => { + directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rustchan-restart-e2e-'))); + baseURL = `http://127.0.0.1:${await freePort()}`; + starts = 0; output = ''; stopped = false; + const admin = spawnSync(binary, ['--data-dir', directory, 'admin', 'create-admin', 'admin', 'restart-test-password'], { + env: { ...process.env, CHAN_TOR_SUPPORT: 'false' }, encoding: 'utf8', timeout: 15_000, + }); + expect(admin.status, `${admin.stdout}\n${admin.stderr}`).toBe(0); + launch(); + await healthy(page); + await login(page); +}); + +test.afterEach(async () => { + stopped = true; + if (child && child.exitCode === null) { + const exited = new Promise((resolve) => child.once('exit', () => resolve())); + child.kill('SIGTERM'); + await exited; + } + fs.rmSync(directory, { recursive: true, force: true }); +}); + +test('no-op and live saves do not restart; pending changes persist across forms until verified restart', async ({ page }) => { + await expect(page.locator('#admin-restart-form')).toHaveCount(0); + await saveNetwork(page, {}); + await expect(page.locator('#admin-restart-form')).toHaveCount(0); + expect(starts).toBe(1); + await page.goto(`${baseURL}/admin/panel?open=appearance`); + const liveForm = page.locator('form[action="/admin/site/settings"]').first(); + const liveFields = await liveForm.locator('input[name],select[name],textarea[name]').evaluateAll((controls) => + Object.fromEntries(controls.filter((control) => !(control instanceof HTMLInputElement && control.type === 'checkbox' && !control.checked)) + .map((control) => [(control as HTMLInputElement).name, (control as HTMLInputElement).value]))); + const live = await page.request.post(`${baseURL}/admin/site/settings`, { form: { ...liveFields, site_name: 'Live restart test' }, headers: { origin: baseURL } }); + expect(live.status(), await live.text()).toBe(200); + await page.goto(`${baseURL}/admin/panel`); + await expect(page.locator('#admin-restart-form')).toHaveCount(0); + expect(starts).toBe(1); + await saveNetwork(page, { rate_limit_gets: '1234' }); + await expect(page.locator('#admin-restart-status')).toContainText('Settings saved. Restart RustChan'); + await saveNetwork(page, { session_duration: '7200' }); + await expect(page.locator('#admin-restart-form')).toBeVisible(); + await page.locator('#settings-restart').screenshot({ path: test.info().outputPath('settings-restart.png') }); + expect(starts).toBe(1); + const previous = await page.locator('#settings-restart').getAttribute('data-restart-instance'); + await restartAndWait(page, previous!); + await expect(page.locator('#admin-restart-status')).toContainText('passed readiness verification'); + await expect(page.locator('#admin-restart-form')).toHaveCount(0); + expect(starts).toBe(2); +}); + +test('failed listener startup restores the last healthy configuration and preserves admin access', async ({ page }) => { + const occupied = net.createServer(); + await new Promise((resolve) => occupied.listen(0, '127.0.0.1', resolve)); + const port = (occupied.address() as net.AddressInfo).port; + try { + // CHAN_HOST keeps the interface fixed; an explicit bind still selects its port. + await saveNetwork(page, { bind_addr: `127.0.0.1:${port}` }); + const previous = await page.locator('#settings-restart').getAttribute('data-restart-instance'); + await restartAndWait(page, previous!); + await expect(page.locator('#admin-restart-status')).toContainText('Previous configuration restored'); + await expect(page.locator('#admin-restart-form')).toHaveCount(0); + expect(starts).toBe(3); + } finally { + await new Promise((resolve) => occupied.close(() => resolve())); + } +}); diff --git a/tools/update_package.py b/tools/update_package.py index f5272614..7fb3e4e1 100644 --- a/tools/update_package.py +++ b/tools/update_package.py @@ -53,7 +53,7 @@ def package(binary, target, version, output): (output / name).write_bytes(artifact) info = dict(version=version, target=target, filename=name, size=len(artifact), sha256=digest(artifact), executable_size=len(data), executable_sha256=digest(data), schema=version, - minimum_schema='1.5.0', minimum_updater='1.5.0', format=1) + minimum_schema='1.5.0', minimum_updater='1.6.0', format=1) (output / f'build-info-{target}.json').write_text(json.dumps(info, sort_keys=True)) return info From 69160cae925a0bb3e1dcda6c16e2575a990164fb Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 00:03:08 -0700 Subject: [PATCH 04/11] Repair Rust media timing, multistream audio and bounded previews --- Cargo.lock | 13 +- Cargo.toml | 7 +- Dockerfile | 3 + docs/media-capabilities.md | 74 ++ docs/non-video-media-migration.md | 2 + docs/strict-lint-exceptions.tsv | 10 +- src/handlers/admin/backup.rs | 2 +- src/handlers/admin/settings/site.rs | 2 +- src/media/audio.rs | 413 ++++++++--- src/media/audio_mp4.rs | 491 +++++++++++++ src/media/audio_opus.rs | 386 ++++++++++ src/media/convert.rs | 75 +- src/media/images.rs | 4 +- src/media/images/tests.rs | 60 +- src/media/pdf.rs | 165 ++++- src/media/probe.rs | 129 +++- src/media/probe/speex.rs | 170 ++++- src/media/thumbnail.rs | 2 +- src/tls/acme.rs | 2 +- src/tls/mod.rs | 4 +- src/tls/self_signed.rs | 4 +- src/workers/mod.rs | 48 +- tests/e2e/non-video-media.spec.ts | 2 +- tests/fixtures/media/README.md | 16 +- tests/fixtures/media/aac-lc-native.f32 | Bin 0 -> 46080 bytes tests/fixtures/media/he-aac-inband.aac | Bin 0 -> 1450 bytes tests/fixtures/media/inline-image.pdf | Bin 0 -> 475 bytes .../media/opus-surround-discrete-native.f32 | Bin 0 -> 276480 bytes .../media/opus-surround-discrete.opus | Bin 0 -> 12777 bytes .../media/opus-surround-distinct-native.f32 | Bin 0 -> 276480 bytes .../media/opus-surround-distinct.opus | Bin 0 -> 12777 bytes .../opus-surround-padded-gain-native.f32 | Bin 0 -> 276480 bytes .../media/opus-surround-padded-gain.opus | Bin 0 -> 12985 bytes .../media/speex-chained-multiplexed.spx | Bin 0 -> 2768 bytes tests/fixtures/media/speex-chained.spx | Bin 0 -> 1384 bytes tests/fixtures/media/speex-multiplexed.spx | Bin 0 -> 1384 bytes vendor/symphonia-codec-aac/Cargo.toml | 72 ++ vendor/symphonia-codec-aac/LICENSE | 373 ++++++++++ vendor/symphonia-codec-aac/README.md | 24 + vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md | 7 + .../symphonia-codec-aac/src/aac/codebooks.rs | 669 ++++++++++++++++++ vendor/symphonia-codec-aac/src/aac/common.rs | 196 +++++ vendor/symphonia-codec-aac/src/aac/cpe.rs | 182 +++++ vendor/symphonia-codec-aac/src/aac/dsp.rs | 159 +++++ .../symphonia-codec-aac/src/aac/ics/gain.rs | 42 ++ vendor/symphonia-codec-aac/src/aac/ics/ltp.rs | 55 ++ vendor/symphonia-codec-aac/src/aac/ics/mod.rs | 636 +++++++++++++++++ .../symphonia-codec-aac/src/aac/ics/pulse.rs | 106 +++ vendor/symphonia-codec-aac/src/aac/ics/tns.rs | 200 ++++++ vendor/symphonia-codec-aac/src/aac/mod.rs | 310 ++++++++ vendor/symphonia-codec-aac/src/aac/window.rs | 63 ++ vendor/symphonia-codec-aac/src/adts.rs | 488 +++++++++++++ vendor/symphonia-codec-aac/src/lib.rs | 23 + vendor/symphonia-codec-aac/tests/tests.rs | 43 ++ 54 files changed, 5503 insertions(+), 229 deletions(-) create mode 100644 docs/media-capabilities.md create mode 100644 src/media/audio_mp4.rs create mode 100644 src/media/audio_opus.rs create mode 100644 tests/fixtures/media/aac-lc-native.f32 create mode 100644 tests/fixtures/media/he-aac-inband.aac create mode 100644 tests/fixtures/media/inline-image.pdf create mode 100644 tests/fixtures/media/opus-surround-discrete-native.f32 create mode 100644 tests/fixtures/media/opus-surround-discrete.opus create mode 100644 tests/fixtures/media/opus-surround-distinct-native.f32 create mode 100644 tests/fixtures/media/opus-surround-distinct.opus create mode 100644 tests/fixtures/media/opus-surround-padded-gain-native.f32 create mode 100644 tests/fixtures/media/opus-surround-padded-gain.opus create mode 100644 tests/fixtures/media/speex-chained-multiplexed.spx create mode 100644 tests/fixtures/media/speex-chained.spx create mode 100644 tests/fixtures/media/speex-multiplexed.spx create mode 100644 vendor/symphonia-codec-aac/Cargo.toml create mode 100644 vendor/symphonia-codec-aac/LICENSE create mode 100644 vendor/symphonia-codec-aac/README.md create mode 100644 vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md create mode 100644 vendor/symphonia-codec-aac/src/aac/codebooks.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/common.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/cpe.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/dsp.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/ics/gain.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/ics/ltp.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/ics/mod.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/ics/pulse.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/ics/tns.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/mod.rs create mode 100644 vendor/symphonia-codec-aac/src/aac/window.rs create mode 100644 vendor/symphonia-codec-aac/src/adts.rs create mode 100644 vendor/symphonia-codec-aac/src/lib.rs create mode 100644 vendor/symphonia-codec-aac/tests/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 49eeb275..191fd425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3607,13 +3607,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "opus-decoder" -version = "0.1.1" +name = "opus-pure" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15ae32c275dabb0cd2c863460bf349e9abc3568ba2abf0f9eb7b7d2edeeed07e" -dependencies = [ - "thiserror 2.0.20", -] +checksum = "4de7c2ad44427a636b27bf0b03eb29e713775ba1b45ff31a0618c19ee5adb215" [[package]] name = "ordered-float" @@ -4658,7 +4655,7 @@ dependencies = [ "image 0.25.10", "ipnet", "kamadak-exif", - "opus-decoder", + "opus-pure", "parking_lot", "pem-rfc7468 1.0.0", "r2d2", @@ -5509,8 +5506,6 @@ dependencies = [ [[package]] name = "symphonia-codec-aac" version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" dependencies = [ "lazy_static", "log", diff --git a/Cargo.toml b/Cargo.toml index 06c65a37..b820f74c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ msrv = "1.99" [workspace] resolver = "2" +exclude = ["vendor/symphonia-codec-aac"] [workspace.package] rust-version = "1.99" @@ -85,7 +86,7 @@ gif = { version = "0.14.2", default-features = false, features = ["std", "color_ # Container inspection only for video; no video decoders are enabled. symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "isomp4", "mkv", "ogg", "wav", "exp-video-codecs"] } # Symphonia supplies Opus packets; this decoder avoids native libopus. -opus-decoder = { version = "0.1.1", default-features = false } +opus-pure = "0.2.2" # HEVC intra still pictures only; no native library or video framework. heic-rs = { version = "0.1.1", default-features = false, features = ["std"] } @@ -275,3 +276,7 @@ missing_assert_message = "deny" large_include_file = "deny" verbose_file_reads = "deny" wildcard_dependencies = "deny" + +# Reject detected in-band SBR instead of publishing AAC-LC-only HE-AAC audio. +[patch.crates-io] +symphonia-codec-aac = { path = "vendor/symphonia-codec-aac" } diff --git a/Dockerfile b/Dockerfile index c494dfb7..bf91df5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,13 @@ FROM rust:1.99.0-bookworm AS builder WORKDIR /build +ARG CARGO_BUILD_JOBS=2 +ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} # BuildKit retains compiled dependencies between builds without carrying Cargo # caches or the source tree into the final image. COPY Cargo.toml Cargo.lock ./ +COPY vendor ./vendor COPY src ./src COPY static ./static RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/docs/media-capabilities.md b/docs/media-capabilities.md new file mode 100644 index 00000000..c08efde0 --- /dev/null +++ b/docs/media-capabilities.md @@ -0,0 +1,74 @@ +# Media capabilities after the Rust follow-up + +This is the current capability audit for `ffmpeg-replace-with-rust`, superseding the historical conclusions in [the original migration report](non-video-media-migration.md). Full removal of FFmpeg except WebM transcoding is **not achieved**. The remaining audio/video compatibility paths are explicit and retained to avoid discarding functionality. No OxiMedia or OxideAV crate is used; the user declined OxideAV. + +## Old behavior and current paths + +“Old” means the pre-migration call sites, with the required executable and codec available. It is not a claim that every file of a named format worked. Unsupported previews preserve the original valid upload; corrupt uploads still follow the existing validation/error contract. + +| Operation / input | Old backend and behavior | Current backend | Verified coverage / remaining limit | +| --- | --- | --- | --- | +| JPEG, PNG, BMP, TIFF, ICO, static WebP | FFmpeg conversion or existing image fallback; resizing, orientation, WebP/PNG size choice | `image`, EXIF orientation, lossless WebP | Decodable output, exact lossless pixels, alpha, orientation, dimensions, metadata stripping, smaller-original PNG policy; hostile/truncated and large transparent images | +| Animated GIF and animated WebP; banners | FFmpeg and existing first-frame fallbacks | Rust compositor, resizer and bounded animated WebP writer | Every composited frame, partial rectangles, disposal, transparency, delays and loop count. GIF 65,535 repeats require 65,536 plays, beyond WebP's finite u16 loop field: preserve the complete original GIF, with its actual MIME and extension, rather than alter its animation | +| HEIC/HEIF still images | External/native image decoder where installed, or image fallback | `heic-rs`, no native HEVC library | Real tiled HEIC, container rotation, auxiliary alpha and iPhone primary SDR picture; all EXIF/ICC/HDR combinations are not claimed. HDR gain-map rendering is outside the established thumbnail behavior | +| Image first-frame thumbnails | FFmpeg or image fallback | Rust image/animation/HEIC decoding | Bounded dimensions, composited first frame, alpha, orientation and original retention | +| MP4/M4A, Matroska/WebM, Ogg and common audio classification / primary codec | FFprobe or bounded FFmpeg stream maps | Symphonia container parsers; small bounded Ogg validators | Content signatures override misleading suffixes, audio-only WebM and multiple tracks, codec names, CRC/sequence/EOS, truncated/ambiguous rejection. Unsupported audio-container mappings still use the explicit FFmpeg probe compatibility path | +| PCM, MP1/2/3, FLAC, Vorbis, ALAC, AAC-LC waveforms | FFmpeg `showwavespic` | Symphonia PCM with streaming mean-absolute channel envelope | WAV/MP3/FLAC/Ogg/ADTS/M4A/Matroska/WebM fixtures, silence, tiny/long/out-of-phase/surround audio, finite sample and packet bounds, cancellation/deadline and atomic publication | +| AAC-LC movie edit lists | FFmpeg applied encoder delay, trim and empty edits | Bounded Rust MP4 metadata walker and streaming presentation timeline | M4A fixture: **5,760** presented 48 kHz frames, versus 7,168 raw decoded frames; independent stereo PCM envelope error ≤0.002. Unit-rate ordered forward ranges and empty edits are supported. Valid dwell, rate changes, repeated/backward edits and edited MP4 Opus use explicit compatibility; every edit is validated before compatibility selection, including malformed entries after a valid unsupported edit | +| Opus voice, stereo and surround | FFmpeg/libopus; earlier Rust surround fallback | `opus-pure` plus Rust multistream framing/mapping | Ogg families 0, 1 and single-stream-container family 255; SILK/CELT, self-delimited VBR/CBR/padding, gain, pre-skip and end trim. Three six-channel golden clips have **11,520** frames and per-channel PCM error ≤0.00004; channel-mean envelope compared independently. Public family 255 probing and decoding are tested | +| Other Opus mappings / containers | FFmpeg accepted mappings supported by its build | Audited compatibility where the existing demuxer exposes the unsupported mapping | Family 2 packet reconstruction is understood by the adapter, but its public Ogg mapping is not implemented. Chained/multiplexed family 255 containers require independent presentation state and are explicitly rejected. No claim of complete arbitrary Opus-container parity | +| HE-AAC / SBR / parametric stereo | FFmpeg full reconstruction where supported | **FFmpeg compatibility retained** | Explicit and sync-extension configurations select compatibility. Patched existing AAC decoder also catches in-band SBR after validating the complete raw-data block; it never publishes the former half-rate AAC-LC-only result. Truncated SBR payloads do not select compatibility | +| Speex NB/WB/UWB waveforms | FFmpeg Speex decode | **FFmpeg compatibility retained**; Rust Ogg framing/metadata validation | Single, all-Speex chained, all-Speex multiplexed and chained multiplexed fixture containers accepted, every logical stream validated (≤64 streams). Mixed-codec containers are rejected. Independent FFmpeg itself reports two decode/timestamp errors on the chained fixture; complete chained waveform parity is not claimed | +| AC-3 and other uncovered audio codecs | FFmpeg codecs installed on the host | **FFmpeg compatibility retained** | AC-3 fixture and precise unsupported-error routing. Generic malformed packet/container, resource, I/O, cancellation and decoder-internal errors cannot trigger fallback | +| Small vector/text PDF first page | `pdftoppm`, `mutool` or `qlmanage` where installed, else SVG | Hayro in Rust, embedded standard fonts | Original vector/text fixtures and a Flate-compressed vector fixture render correctly; compressed old-Poppler output independently confirms the former capability. Aggregate source/expanded content ≤2 MiB, flat page tree ≤4,096 objects, syntax/numeric/depth/work limits, decompression checksum/trailing-data validation | +| Inline-image, nested-resource, embedded-font, encrypted or other complex PDFs | Some documents rendered by an available old external renderer; otherwise SVG | **SVG preview; original PDF retained** | A valid inline RGB image fixture is verified separately with old Poppler. Current gate rejects inline image operators, decompression bombs, recursive/indirect trees and unbudgeted resources before Hayro. This is a remaining preview capability regression, not full PDF parity | +| Video first-frame thumbnail, including MP4/MKV/WebM | FFmpeg video decoding and WebP encoding | **FFmpeg retained** | Standalone H.264 candidate decodes ordinary High 8-bit CABAC, but genuine High 10-bit and High 4:4:4 fixtures fail while the old decoder succeeds. Removing the path would lose existing thumbnail capability. No broad video framework was added | +| Video → VP9/Opus **WebM** | FFmpeg/libvpx/libopus | **FFmpeg retained, intended exception** | Existing arguments, color handling, timeout/cancellation, atomic durable jobs, generated container validation and stale-original redirects | + +## Exact subprocess boundary + +There is no production `ffprobe` command and no external image/PDF renderer command. The runtime image removes `/usr/bin/ffprobe`. FFmpeg commands remain at: + +1. `src/media/ffmpeg.rs::ffmpeg_thumbnail`, called only by the **video** thumbnail branch. +2. `src/workers/mod.rs::render_waveform_with_compatibility`, using the existing bounded waveform command only for audited unsupported audio capabilities. +3. `src/media/ffmpeg.rs::probe_uncovered_audio`, two bounded zero-frame audio/video maps after an explicitly unsupported audio container result. +4. `src/workers/mod.rs::transcode_video` and `build_vp9_transcode_args`, for VP9/Opus **WebM output**. +5. Startup/admin executable `-version` and `-encoders` capability checks for that retained backend. + +`src/media/process.rs` bounds subprocess output, deadlines and process-group cleanup; `/bin/kill` is process cleanup, not codec processing. Retaining calls 1–3 conflicts with the requested WebM-only boundary. They remain visible because removing them under the current constraints would drop working capabilities. + +## Dependency and candidate review + +- Replace `opus-decoder` 0.1.1 with **`opus-pure` 0.2.2**, BSD-3-Clause, MSRV 1.88, no dependencies, build script, C or FFI. RustChan's MSRV remains **1.99**. +- Vendor the already-used **`symphonia-codec-aac` 0.6.1**, MPL-2.0, for one explicit SBR error after full block parsing. No decoder reconstruction or dependency closure is added. See [patch provenance](../vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md); upstream MSRV 1.85 is below the application requirement. +- PDF expansion reuses existing `flate2` and its Rust backend; no new PDF dependency. +- `fdk-aac-rust` 0.2.3 was evaluated outside the application: strict decoding rejects the legitimate in-band fixture with `NonZeroTrailingBits`; its standard API returns 24 kHz core-sized PCM with unusable comparison error. It was not adopted. +- [`rusty_aac` 0.5.0](https://docs.rs/crate/rusty_aac/0.5.0) explicitly provides core-only HE-AAC, without SBR/PS reconstruction. Configuration recognition cannot establish PCM parity. +- [`speexdsp-rs`](https://github.com/rust-av/speexdsp-rs) is DSP/resampling, not a Speex codec. Available native Speex bindings violate the requested implementation boundary; OxideAV was declined. No suitable alternative decoder reached validation. +- `rusty_h264-decoder` 0.16.0 was tested on 8-bit High, High 10-bit and High 4:4:4 synthetic streams. Ordinary High/CABAC works, so stale claims that it cannot decode CABAC were discarded. The other two fail on bit depth/chroma support. A partial H.264 path would not replace all existing video-thumbnail codecs/profiles. +- Hayro lacks a general allocation/work or cooperative cancellation interface for arbitrary PDF programs ([upstream issue](https://github.com/LaurenzV/hayro/issues/1052)). The bounded subset is intentional; enabling arbitrary resource graphs would not satisfy the input/resource contract. + +## Validation evidence + +Validated on 2026-10-02 with native Apple Silicon Rust **1.99.0** (`b940084d7`, 2026-09-28) and an actual Linux arm64 Docker build using the official `rust:1.99.0-bookworm` image. The previously reported unavailable Docker tag is no longer a blocker. Build parallelism was limited to two Cargo jobs per environment. + +```sh +cargo fmt --all --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features +CHAN_FFMPEG_PATH=/nonexistent/rustchan-validation-ffmpeg CHAN_REQUIRE_FFMPEG=0 \ + cargo test --locked --workspace --all-features -- --test-threads=4 +cargo doc --locked --workspace --all-features --no-deps +cargo deny --locked check +cargo build --release --locked --workspace --all-features +docker build --build-arg CARGO_BUILD_JOBS=2 -t rustchan-media-validation:local . +``` + +- The integrated no-tools workspace run passed **2,619** tests: **1,306** in each library/CLI test binary, five CLI integration tests, one UI fixture test and one rustdoc test. Seven Python update-packaging tests also passed. Formatting, check, canonical strict Clippy, documentation, locked dependency policy and release builds passed. +- A process-spy executable recorded **zero invocations** for all 16 Rust audio tests. The worker routing test recorded exactly seven `-version` capability checks for the seven uncovered audio fixtures, and no codec command. Repeating that test with the installed FFmpeg build containing Speex passed all seven real compatibility waveform paths. A separately built validation FFmpeg without Speex failed this check, demonstrating that fallback still requires the actual codec rather than merely an executable. +- Local Chromium and Chromium without JavaScript passed the media/upload/error suites: **14 tests passed, four intentional JavaScript-only skips**, including 55 JavaScript and 54 no-JavaScript upload-validation scenarios and 14 common audio formats per browser context. The separate restart suite passed **four** tests, and the opt-in real FFmpeg toolchain suite passed **one** test. Browser tests remain local; no hosted browser Actions were introduced. +- The final Linux runtime ran as UID **10001**, with `ffmpeg` and `ffprobe` physically absent from executable search paths and the saved FFmpeg file mode `000`. **42** real upload → processing → preview → download journeys passed across JavaScript and no-JavaScript contexts. These included all 14 audio fixtures, rotated/alpha HEIC, simple/compressed/unsupported PDF and both converted and maximum-loop GIFs. Original unsupported PDFs and the unrepresentable-loop GIF were retained byte for byte. Independent Pillow checks read generated previews and every animation frame; no FFmpeg participates in these production paths. +- Separately enabled Docker FFmpeg passed two real MP4 → VP9/Opus WebM browser journeys, with WebP thumbnails and stale-original redirects where the conversion raced the first response. Two actual Docker restart-policy journeys verified replacement process identities, readiness, saved settings application, JavaScript reconnect and no-JavaScript recovery. The runtime uses Debian FFmpeg **5.1.9** for the explicitly retained compatibility/video backend. +- Synthetic fixtures and independent PCM reference files are checked in under `tests/fixtures/media`. Private browser data, candidate experiments, screenshots, logs and preserved runnable binaries are kept outside `target` and outside the Git deliverable. The final handoff gives their location and the exact commit, image and required-CI results. + +Native systemd restart operation remains unverified on this macOS host; the previous owner's managed-Linux tests used mocked service control. Windows and configured Linux/macOS build coverage depends on the required GitHub platform checks and is reported separately. Full HE-AAC/Speex reconstruction, arbitrary video thumbnails and complex PDF rendering remain the precise functional limits above; a successful test run does not establish the requested WebM-only FFmpeg boundary. diff --git a/docs/non-video-media-migration.md b/docs/non-video-media-migration.md index 220f7a0c..4b5e9171 100644 --- a/docs/non-video-media-migration.md +++ b/docs/non-video-media-migration.md @@ -1,5 +1,7 @@ # Non-video media backend migration +> Historical report for the initial migration at `16f4b91`. The current [capability matrix and follow-up evidence](media-capabilities.md) supersede the uncommitted status, Opus compatibility limitations and unavailable Docker-base conclusions below. Remaining compatibility calls and PDF limitations are still explicit. + Implemented backend migration, with the explicitly authorized FFmpeg compatibility fallback for uncovered audio codecs/variants. All changes remain uncommitted. Rust 1.99 is now the minimum in Cargo, CI, release builds, Docker and setup documentation. Actual video frame decoding and VP9/Opus WebM transcoding remain on FFmpeg. **Final verification conclusion:** suitable for review, but production readiness is not established. The final pass below records passing validation, local fixes, remaining AAC/Speex compatibility limits, and the failed Docker base-image resolution. Do not treat the initial migration results as release approval. diff --git a/docs/strict-lint-exceptions.tsv b/docs/strict-lint-exceptions.tsv index 37b0f877..aa34f802 100644 --- a/docs/strict-lint-exceptions.tsv +++ b/docs/strict-lint-exceptions.tsv @@ -189,8 +189,8 @@ src/media/prune.rs 524 expect clippy::panic_in_result_fn test assertions intenti src/media/prune.rs 595 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_removes_secondary_audio_original_with_combo_post src/media/prune.rs 657 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_media_paths src/media/prune.rs 711 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn pruning_refuses_symlink_parent_components -src/media/thumbnail.rs 496 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg -src/media/thumbnail.rs 511 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg +src/media/thumbnail.rs 497 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn write_pdf_placeholder_outputs_svg +src/media/thumbnail.rs 512 expect clippy::panic_in_result_fn test assertions intentionally panic on failure fn unsupported_image_thumbnail_falls_back_to_svg_placeholder_without_ffmpeg src/middleware/state.rs 342 expect clippy::struct_excessive_bools independent runtime capability flags are shared across handlers and workers struct AppState src/middleware/state.rs 435 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_assign_monotonic_job_ids_and_preserve_them_in_terminal_states src/middleware/state.rs 527 expect clippy::panic_in_result_fn assertions provide precise regression failures after fallible test-database setup fn db_maintenance_jobs_finished_updates_only_matching_running_job @@ -370,10 +370,10 @@ tests/cli_runtime.rs 304 expect clippy::panic_in_result_fn assertions intentiona tests/cli_runtime.rs 356 expect clippy::panic_in_result_fn assertions intentionally report violated CLI filesystem invariants fn relative_data_dir_is_rejected_before_filesystem_mutation tests/cli_runtime.rs 396 expect clippy::too_many_lines one subprocess lifecycle verifies the complete direct-HTTPS topology and cookie contract fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies tests/cli_runtime.rs 400 expect clippy::panic_in_result_fn assertions intentionally report violated TLS topology and cookie invariants fn direct_https_process_exposes_only_https_and_redirect_with_secure_login_cookies -src/media/audio.rs 440 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions clamped normalized amplitudes map to a bounded pixel radius fn render_peaks +src/media/audio.rs 521 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions clamped normalized amplitudes map to a bounded pixel radius fn render_peaks src/media/heif.rs 12 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode src/media/images.rs 21 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn validate_dimensions src/media/images.rs 35 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn decode_still src/media/images.rs 74 expect clippy::redundant_pub_crate keep codec helpers internal while allowing upload and banner callers fn image_to_webp -src/media/pdf.rs 30 expect clippy::redundant_pub_crate PDF rendering is an internal media helper fn render -src/media/pdf.rs 34 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions finite positive viewport dimensions are clamped to a checked u16 bound before rounding fn render +src/media/pdf.rs 31 expect clippy::redundant_pub_crate PDF rendering is an internal media helper fn render +src/media/pdf.rs 35 expect clippy::cast_possible_truncation,clippy::cast_sign_loss,clippy::as_conversions finite positive viewport dimensions are clamped to a checked u16 bound before rounding fn render diff --git a/src/handlers/admin/backup.rs b/src/handlers/admin/backup.rs index b353d752..96db634b 100644 --- a/src/handlers/admin/backup.rs +++ b/src/handlers/admin/backup.rs @@ -364,7 +364,7 @@ fn count_files_in_dir(dir: &Path) -> u64 { /// /// MEM-FIX: Uses `std::io::copy` with the zip writer directly, streaming each /// file through a kernel buffer (~8 KiB) instead of reading the whole file -/// into a Vec first. Peak RAM per file = `io::copy`'s 8 KiB stack buffer. +/// into a `Vec` first. Peak RAM per file = `io::copy`'s 8 KiB stack buffer. /// /// Progress tracking: increments `progress.files_done` and `progress.bytes_done` /// after each file is written to the zip. diff --git a/src/handlers/admin/settings/site.rs b/src/handlers/admin/settings/site.rs index 9c058e3c..3a4ae9ea 100644 --- a/src/handlers/admin/settings/site.rs +++ b/src/handlers/admin/settings/site.rs @@ -10,7 +10,7 @@ use serde::Deserialize; pub(in crate::server) struct SiteSettingsForm { #[serde(rename = "_csrf")] pub csrf: Option, - /// Custom site name (replaces [ `RustChan` ] on home page and footer). + /// Custom site name (replaces `RustChan` on home page and footer). pub site_name: Option, /// Custom home page subtitle line below the site name. pub site_subtitle: Option, diff --git a/src/media/audio.rs b/src/media/audio.rs index 7e8346b8..615caf5b 100644 --- a/src/media/audio.rs +++ b/src/media/audio.rs @@ -2,7 +2,10 @@ use anyhow::{ensure, Context as _, Result}; use image::{Rgba, RgbaImage}; -use opus_decoder::OpusDecoder; +#[path = "audio_mp4.rs"] +mod mp4; +#[path = "audio_opus.rs"] +mod opus; use std::path::Path; use std::time::{Duration, Instant}; use symphonia::core::codecs::audio::well_known::CODEC_ID_OPUS; @@ -16,16 +19,18 @@ use tokio_util::sync::CancellationToken; pub(crate) fn is_unsupported(error: &anyhow::Error) -> bool { // Symphonia also calls missing MP4 atoms and excessive MKV geometry // Unsupported. Only audited codec limitations may select compatibility. - // Opus InternalError includes transform failures, so it must fail closed. + // Opus reconstruction and packet errors fail closed. error.chain().any(|cause| { matches!( cause.downcast_ref::(), Some(symphonia::core::errors::Error::Unsupported( - "Opus multistream requires the compatibility decoder" + "Opus mapping family requires the compatibility decoder" | "AAC profile needs the compatibility decoder" | "audio codec needs the compatibility decoder" | "Speex requires the compatibility decoder" | "aac: aac too complex" + | "aac: spectral band replication is unsupported" + | "audio edit list requires the compatibility decoder" )) ) }) @@ -38,8 +43,8 @@ const MAX_PACKET_SAMPLES: usize = 4 * 1024 * 1024; enum PacketDecoder { /// Mature general-purpose pure-Rust audio decoders. Symphonia(Box), - /// Single Opus stream, mono or stereo. - Opus(Box), + /// Bounded mono/stereo/multistream Opus with explicit channel mappings. + Opus(Box), } impl PacketDecoder { @@ -62,43 +67,17 @@ impl PacketDecoder { .extra_data .as_deref() .context("Opus header missing")?; - ensure!(header.get(..8) == Some(b"OpusHead"), "invalid Opus header"); - ensure!( - header.get(9).copied().map(usize::from) == Some(channels), - "Opus channels disagree with container" - ); - let mapping = *header.get(18).context("truncated Opus mapping")?; - if mapping == 0 { - ensure!(channels <= 2, "invalid single-stream Opus channel count"); - Ok(Self::Opus(Box::new(OpusDecoder::new(48_000, channels)?))) - } else { - let streams = usize::from(*header.get(19).context("truncated Opus stream count")?); - let coupled = usize::from(*header.get(20).context("truncated Opus coupled count")?); - let channel_map = header - .get(21..21 + channels) - .context("truncated Opus channel map")?; - ensure!( - streams > 0 && coupled <= streams && streams + coupled <= 255, - "invalid Opus stream mapping" - ); - ensure!( - channel_map - .iter() - .all(|slot| *slot == 255 || usize::from(*slot) < streams + coupled), - "invalid Opus channel mapping" - ); - // The current focused decoder's multistream packet splitter is not - // RFC 6716 Appendix B compatible (a valid libopus 5.1 fixture fails). - // Preserve this variant through the authorized compatibility path. - Err(symphonia::core::errors::Error::Unsupported( - "Opus multistream requires the compatibility decoder", - ) - .into()) - } + Ok(Self::Opus(Box::new(opus::Decoder::new(header, channels)?))) } /// Decode one packet into a reused bounded float buffer. - fn decode(&mut self, packet: &Packet, channels: usize, samples: &mut Vec) -> Result<()> { + fn decode( + &mut self, + packet: &Packet, + channels: usize, + samples: &mut Vec, + budget: &AudioBudget<'_>, + ) -> Result<()> { match self { Self::Symphonia(decoder) => { let pcm = decoder.decode(packet).context("decode audio packet")?; @@ -115,19 +94,7 @@ impl PacketDecoder { pcm.copy_to_slice_interleaved(samples.as_mut_slice()); } Self::Opus(decoder) => { - ensure!(!packet.data.is_empty(), "empty Opus packet"); - samples.resize( - OpusDecoder::MAX_FRAME_SIZE_48K - .checked_mul(channels) - .context("Opus buffer overflow")?, - 0.0, - ); - let frames = decoder.decode_float(&packet.data, samples, false)?; - samples.truncate( - frames - .checked_mul(channels) - .context("Opus frame overflow")?, - ); + decoder.decode(&packet.data, samples, || budget.check())?; } } ensure!( @@ -138,6 +105,72 @@ impl PacketDecoder { } } +/// Validate a discrete Opus identification header without decoding PCM. +pub(crate) fn validate_discrete_opus_header( + header: &[u8], + head: &opus_pure::OpusHead, +) -> Result<()> { + let channels = usize::from(head.channel_count); + opus::Decoder::validate(header, channels)?; + ensure!( + header.get(18) == Some(&head.mapping_family) + && header.get(19) == Some(&head.stream_count) + && header.get(20) == Some(&head.coupled_count) + && header.get(21..21 + channels) == Some(head.channel_mapping.as_slice()) + && header.get(10..12) == Some(head.pre_skip.to_le_bytes().as_slice()) + && header.get(16..18) == Some(head.output_gain_q8.to_le_bytes().as_slice()), + "Opus headers changed during validation" + ); + Ok(()) +} + +/// Stream discrete Ogg layouts through the same bounded, explicitly mapped decoder. +fn visit_discrete_opus( + input: &Path, + header: &[u8], + budget: &AudioBudget<'_>, + mut visit: impl FnMut(f32) -> Result<()>, +) -> Result { + use opus_pure::{OggOpusReader, OggPacket, Trim}; + let (source, reads) = super::probe::open_discrete_opus_source( + input, + budget.started, + budget.timeout, + budget.cancel, + )?; + let mut reader = OggOpusReader::new(source)?; + validate_discrete_opus_header(header, reader.head())?; + let channels = usize::from(reader.head().channel_count); + let mut decoder = opus::Decoder::new(header, channels)?; + let mut trim = Trim::new(reader.head(), 48_000, channels)?; + let gain = 10.0_f32.powf(f32::from(reader.head().output_gain_q8) / (256.0 * 20.0)); + let mut packet = OggPacket::default(); + let mut samples = Vec::new(); + let mut frames = 0_u64; + loop { + budget.check()?; + reads.start_packet(); + if !reader.read_packet_into(&mut packet)? { + break; + } + decoder.decode(&packet.data, &mut samples, || budget.check())?; + ensure!( + samples.iter().all(|sample| sample.is_finite()), + "non-finite Opus PCM" + ); + for frame in trim.keep(&packet, &samples).chunks_exact(channels) { + let amplitude = frame_amplitude(frame, gain)?; + visit(amplitude)?; + frames = frames + .checked_add(1) + .context("Opus sample count overflow")?; + } + } + budget.check()?; + ensure!(frames > 0, "Opus contains no decodable samples"); + Ok(frames) +} + /// Opus presentation metadata is independent of the PCM decoder state. struct OpusPresentation { /// Exact remaining delay from the identification header, in 48 kHz samples. @@ -292,6 +325,46 @@ impl AudioBudget<'_> { } } +/// Reject audited unavailable codecs while distinguishing malformed configurations. +fn validate_audio_codec(params: &AudioCodecParameters) -> Result { + if params.codec == symphonia::core::codecs::audio::well_known::CODEC_ID_AAC + && params.extra_data.as_deref().map_or_else( + || Ok(params.profile.is_some_and(|profile| { + profile != symphonia::core::codecs::audio::well_known::profiles::CODEC_PROFILE_AAC_LC + })), + aac_needs_compatibility, + )? + { + return Err(symphonia::core::errors::Error::Unsupported( + "AAC profile needs the compatibility decoder", + ) + .into()); + } + let is_opus = params.codec == CODEC_ID_OPUS; + if !is_opus + && symphonia::default::get_codecs() + .get_audio_decoder(params.codec) + .is_none() + { + return Err(symphonia::core::errors::Error::Unsupported( + "audio codec needs the compatibility decoder", + ) + .into()); + } + Ok(is_opus) +} + +/// Mean absolute channel amplitude avoids cancellation of out-of-phase stereo. +fn frame_amplitude(frame: &[f32], gain: f32) -> Result { + let channels = u16::try_from(frame.len())?; + ensure!(channels > 0, "empty audio channel frame"); + Ok(frame + .iter() + .map(|sample| (sample * gain).abs().min(1.0)) + .sum::() + / f32::from(channels)) +} + /// Walk decoded channel frames without retaining the clip in memory. fn visit_samples( input: &Path, @@ -305,6 +378,14 @@ fn visit_samples( ) .into()); } + if let Some(header) = super::probe::discrete_opus_header( + input, + budget.started, + budget.timeout, + Some(budget.cancel), + )? { + return visit_discrete_opus(input, &header, budget, visit); + } let (mut format, read_budget) = super::probe::open_audio_format(input, budget.started, budget.timeout, budget.cancel)?; let track = format @@ -316,30 +397,19 @@ fn visit_samples( .as_ref() .and_then(symphonia::core::codecs::CodecParameters::audio) .context("audio codec parameters missing")?; - if params.codec == symphonia::core::codecs::audio::well_known::CODEC_ID_AAC - && params.extra_data.as_deref().map_or_else( - || Ok(params.profile.is_some_and(|profile| { - profile != symphonia::core::codecs::audio::well_known::profiles::CODEC_PROFILE_AAC_LC - })), - aac_needs_compatibility, + let mut movie_presentation = if format.format_info().short_name == "isomp4" { + mp4::read( + input, + track_id, + params + .sample_rate + .context("MP4 audio sample rate missing")?, + || budget.check(), )? - { - return Err(symphonia::core::errors::Error::Unsupported( - "AAC profile needs the compatibility decoder", - ) - .into()); - } - let is_opus = params.codec == CODEC_ID_OPUS; - if !is_opus - && symphonia::default::get_codecs() - .get_audio_decoder(params.codec) - .is_none() - { - return Err(symphonia::core::errors::Error::Unsupported( - "audio codec needs the compatibility decoder", - ) - .into()); - } + } else { + None + }; + let is_opus = validate_audio_codec(params)?; let mut opus = if is_opus { Some(OpusPresentation::new(params, track.time_base)?) } else { @@ -356,6 +426,12 @@ fn visit_samples( "audio channels exceed safety limit" ); let mut decoder = PacketDecoder::new(params, channels)?; + if is_opus && movie_presentation.is_some() { + return Err(symphonia::core::errors::Error::Unsupported( + "audio edit list requires the compatibility decoder", + ) + .into()); + } let mut samples = Vec::new(); let mut frames = 0_u64; loop { @@ -367,7 +443,7 @@ fn visit_samples( if packet.track_id != track_id { continue; } - decoder.decode(&packet, channels, &mut samples)?; + decoder.decode(&packet, channels, &mut samples, budget)?; let presentation = if let Some(opus) = &mut opus { opus.samples(&packet, &samples, channels)? } else { @@ -375,18 +451,23 @@ fn visit_samples( }; // Mean absolute channel amplitude avoids cancelling out-of-phase stereo. for frame in presentation.chunks_exact(channels) { - let divisor = f32::from(u16::try_from(channels)?); - let amplitude = frame - .iter() - .map(|sample| (sample * gain).abs().min(1.0)) - .sum::() - / divisor; - visit(amplitude)?; + let amplitude = frame_amplitude(frame, gain)?; + let emitted = if let Some(presentation) = &mut movie_presentation { + presentation.frame(amplitude, &mut visit, || budget.check())? + } else { + visit(amplitude)?; + 1 + }; frames = frames - .checked_add(1) + .checked_add(emitted) .context("audio sample count overflow")?; } } + if let Some(presentation) = &mut movie_presentation { + frames = frames + .checked_add(presentation.finish(&mut visit, || budget.check())?) + .context("audio sample count overflow")?; + } ensure!(frames > 0, "audio contains no decodable samples"); budget.check()?; Ok(frames) @@ -471,9 +552,9 @@ mod tests { .into() )); for error in [ - anyhow::Error::from(opus_decoder::OpusError::InternalError), - anyhow::Error::from(opus_decoder::OpusError::InvalidPacket), - anyhow::Error::from(opus_decoder::OpusError::BufferTooSmall), + anyhow::Error::from(opus_pure::Error::Internal("transform failure")), + anyhow::Error::from(opus_pure::Error::InvalidPacket("invalid framing")), + anyhow::Error::from(opus_pure::Error::InvalidArgument("output buffer budget")), anyhow::Error::from(symphonia::core::errors::Error::DecodeError( "invalid header", )), @@ -520,6 +601,7 @@ mod tests { "tone.ogg", "tone.opus", "speech-mode.opus", + "surround.opus", "tone.aac", "tone.m4a", "tone-alac.m4a", @@ -698,6 +780,101 @@ mod tests { Ok(()) } + /// libopus reference samples verify exact duration, mapping, padding and gain. + #[test] + fn mapped_opus_matches_independent_pcm_without_external_tools() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let cancel = CancellationToken::new(); + for name in ["opus-surround-distinct", "opus-surround-padded-gain"] { + let expected = std::fs::read(root.join(format!("{name}-native.f32")))?; + ensure!( + expected.len().is_multiple_of(24), + "invalid six-channel reference" + ); + let expected = expected + .as_chunks::<24>() + .0 + .iter() + .map(|frame| { + frame + .as_chunks::<4>() + .0 + .iter() + .map(|sample| f32::from_le_bytes(*sample).abs().min(1.0)) + .sum::() + / 6.0 + }) + .collect::>(); + let mut actual = Vec::new(); + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(10), + cancel: &cancel, + }; + let frames = visit_samples(&root.join(format!("{name}.opus")), &budget, |amplitude| { + actual.push(amplitude); + Ok(()) + }) + .with_context(|| format!("decode {name}"))?; + ensure!( + frames == 11_520 && actual.len() == expected.len(), + "Opus presentation duration changed for {name}" + ); + ensure!( + actual + .iter() + .zip(&expected) + .all(|(a, b)| (a - b).abs() <= 0.000_04), + "Opus amplitude differs from libopus for {name}" + ); + } + Ok(()) + } + + /// The native reference applies the movie edit list, including encoder delay. + #[test] + fn aac_lc_movie_edits_preserve_presentation_duration_and_waveform() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let bytes = std::fs::read(root.join("aac-lc-native.f32"))?; + let expected = bytes + .as_chunks::<8>() + .0 + .iter() + .map(|frame| { + frame + .as_chunks::<4>() + .0 + .iter() + .map(|sample| f32::from_le_bytes(*sample).abs()) + .sum::() + / 2.0 + }) + .collect::>(); + let cancel = CancellationToken::new(); + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(10), + cancel: &cancel, + }; + let mut actual = Vec::new(); + let frames = visit_samples(&root.join("tone.m4a"), &budget, |amplitude| { + actual.push(amplitude); + Ok(()) + })?; + ensure!( + frames == 5_760 && actual.len() == expected.len(), + "AAC encoder padding changed movie presentation" + ); + ensure!( + actual + .iter() + .zip(&expected) + .all(|(a, b)| (a - b).abs() <= 0.002), + "AAC movie waveform differs from independent decoder" + ); + Ok(()) + } + #[test] fn implicit_he_aac_is_unsupported_and_truncated_extensions_are_invalid() -> Result<()> { ensure!( @@ -723,4 +900,66 @@ mod tests { } Ok(()) } + #[test] + fn in_band_sbr_never_publishes_core_only_waveforms() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let input = root.join("he-aac-inband.aac"); + let dir = tempfile::tempdir()?; + let output = dir.path().join("wave.png"); + let cancel = CancellationToken::new(); + let error = render_waveform(&input, &output, 100, 50, &cancel) + .err() + .context("in-band HE-AAC was decoded as core-only AAC-LC")?; + ensure!( + is_unsupported(&error), + "SBR did not select explicit compatibility: {error:#}" + ); + ensure!(!output.exists(), "incomplete SBR waveform was published"); + let (mut format, _) = super::super::probe::open_audio_format( + &input, + Instant::now(), + Duration::from_secs(5), + &cancel, + )?; + let track = format + .first_track(TrackType::Audio) + .context("no AAC track")?; + let params = track + .codec_params + .as_ref() + .and_then(symphonia::core::codecs::CodecParameters::audio) + .context("no AAC parameters")? + .clone(); + let channels = params.channels.as_ref().context("no AAC channels")?.count(); + let packet = format.next_packet()?.context("no AAC packet")?; + let budget = AudioBudget { + started: Instant::now(), + timeout: Duration::from_secs(5), + cancel: &cancel, + }; + for removed in 1..=3 { + let mut truncated = packet.clone(); + truncated.data = packet + .data + .get( + ..packet + .data + .len() + .checked_sub(removed) + .context("AAC packet too short")?, + ) + .context("AAC fixture truncation outside packet")? + .to_vec() + .into_boxed_slice(); + let error = PacketDecoder::new(¶ms, channels)? + .decode(&truncated, channels, &mut Vec::new(), &budget) + .err() + .context("truncated AAC raw-data block was accepted")?; + ensure!( + !is_unsupported(&error), + "truncated SBR payload selected compatibility: {error:#}" + ); + } + Ok(()) + } } diff --git a/src/media/audio_mp4.rs b/src/media/audio_mp4.rs new file mode 100644 index 00000000..f3fb85a0 --- /dev/null +++ b/src/media/audio_mp4.rs @@ -0,0 +1,491 @@ +//! Bounded MP4 edit-list timing missing from Symphonia's current demuxer. + +use anyhow::{ensure, Context as _, Result}; +use std::fs::File; +use std::io::{Read as _, Seek as _, SeekFrom}; +use std::path::Path; + +/// Maximum framing boxes visited, regardless of their declared payload size. +const MAX_BOXES: usize = 4096; +/// Maximum retained presentation segments. +const MAX_EDITS: usize = 64; + +/// One explicitly declared segment of the movie's audio presentation. +enum Edit { + /// Empty edits introduce real presentation silence. + Silence(u64), + /// Unit-rate, forward-moving range of decoded media frames. + Audio { + /// First raw decoded frame included in this edit. + start: u64, + /// Exclusive end of this edit's raw decoded range. + end: u64, + }, +} + +/// Streaming selection without retaining PCM or allocating silence buffers. +pub(super) struct Presentation { + /// Bounded edit plan, expressed in decoded sample frames. + edits: Vec, + /// Next edit to emit. + next: usize, + /// Current raw decoded frame position. + position: u64, +} + +impl Presentation { + /// Emit explicit empty edits with deadline checks throughout long silence. + fn silence( + &mut self, + visit: &mut impl FnMut(f32) -> Result<()>, + check: &mut impl FnMut() -> Result<()>, + ) -> Result { + let mut emitted = 0_u64; + while let Some(Edit::Silence(count)) = self.edits.get(self.next) { + for frame in 0..*count { + if frame % 1024 == 0 { + check()?; + } + visit(0.0)?; + } + emitted = emitted + .checked_add(*count) + .context("MP4 presentation length overflow")?; + self.next += 1; + } + Ok(emitted) + } + + /// Select one raw frame and advance every completed edit exactly once. + pub(super) fn frame( + &mut self, + amplitude: f32, + visit: &mut impl FnMut(f32) -> Result<()>, + mut check: impl FnMut() -> Result<()>, + ) -> Result { + let mut emitted = self.silence(visit, &mut check)?; + if let Some(Edit::Audio { start, end }) = self.edits.get(self.next) { + if self.position >= *start && self.position < *end { + visit(amplitude)?; + emitted = emitted + .checked_add(1) + .context("MP4 presentation length overflow")?; + } + if self + .position + .checked_add(1) + .context("MP4 frame position overflow")? + >= *end + { + self.next += 1; + } + } + self.position = self + .position + .checked_add(1) + .context("MP4 frame position overflow")?; + Ok(emitted) + } + + /// Reject a range past decoded media rather than inventing missing samples. + pub(super) fn finish( + &mut self, + visit: &mut impl FnMut(f32) -> Result<()>, + mut check: impl FnMut() -> Result<()>, + ) -> Result { + let emitted = self.silence(visit, &mut check)?; + ensure!( + self.next == self.edits.len(), + "MP4 edit extends beyond decoded audio" + ); + Ok(emitted) + } +} + +/// Checked framing locations; encoded sample payloads are never retained. +#[derive(Clone, Copy)] +struct Atom { + /// Four-byte container box type. + kind: [u8; 4], + /// First byte after the normal or extended framing header. + body: u64, + /// Exclusive boundary, checked against its enclosing box. + end: u64, +} + +/// Deadline-aware metadata walker with a fixed allocation ceiling. +struct Reader { + /// Validated regular upload file. + file: File, + /// Total framing boxes inspected across every nesting level. + boxes: usize, + /// Job deadline and cancellation check. + check: F, +} + +impl Result<()>> Reader { + /// Read one fixed-size field after checking its container boundary. + fn field(&mut self, atom: Atom, offset: u64) -> Result<[u8; N]> { + (self.check)()?; + let start = atom + .body + .checked_add(offset) + .context("MP4 field offset overflow")?; + ensure!( + start + .checked_add(u64::try_from(N)?) + .is_some_and(|end| end <= atom.end), + "truncated MP4 timing field" + ); + self.file.seek(SeekFrom::Start(start))?; + let mut bytes = [0; N]; + self.file.read_exact(&mut bytes)?; + Ok(bytes) + } + + /// Walk only direct child headers, skipping bounded payload extents. + fn children(&mut self, start: u64, end: u64) -> Result> { + let mut cursor = start; + let mut atoms = Vec::new(); + while cursor < end { + (self.check)()?; + ensure!( + self.boxes < MAX_BOXES && end - cursor >= 8, + "MP4 timing box budget or extent invalid" + ); + self.boxes += 1; + let outer = Atom { + kind: [0; 4], + body: cursor, + end, + }; + let size = u32::from_be_bytes(self.field::<4>(outer, 0)?); + let kind = self.field::<4>(outer, 4)?; + let (size, header) = match size { + 0 => (end - cursor, 8), + 1 => (u64::from_be_bytes(self.field::<8>(outer, 8)?), 16), + size => (u64::from(size), 8), + }; + let next = cursor + .checked_add(size) + .context("MP4 box extent overflow")?; + ensure!( + size >= header && next <= end, + "invalid MP4 timing box extent" + ); + atoms.push(Atom { + kind, + body: cursor + header, + end: next, + }); + cursor = next; + } + Ok(atoms) + } + + /// Validate full-box version and select its 32/64-bit timestamp fields. + fn timestamp_offset(&mut self, atom: Atom) -> Result { + match self.field::<1>(atom, 0)? { + [0] => Ok(12), + [1] => Ok(20), + _ => anyhow::bail!("invalid MP4 timing version"), + } + } + + /// Decode a nonzero movie or media timescale. + fn timescale(&mut self, atom: Atom) -> Result { + let offset = self.timestamp_offset(atom)?; + let scale = u32::from_be_bytes(self.field::<4>(atom, offset)?); + ensure!(scale > 0, "zero MP4 timescale"); + Ok(scale) + } +} + +/// Require exactly one structural timing box rather than choosing duplicates. +fn one(atoms: &[Atom], kind: [u8; 4]) -> Result { + let mut found = atoms.iter().filter(|atom| atom.kind == kind); + let atom = *found.next().context("missing MP4 timing box")?; + ensure!(found.next().is_none(), "duplicate MP4 timing box"); + Ok(atom) +} + +/// Convert rational timestamps to the nearest whole decoded sample frame. +fn frames(units: u64, scale: u32, rate: u32) -> Result { + ensure!(scale > 0 && rate > 0, "invalid MP4 timing scale"); + Ok(u64::try_from( + (u128::from(units) * u128::from(rate) + u128::from(scale) / 2) / u128::from(scale), + )?) +} + +/// Valid but unimplemented temporal operations retain an explicit compatibility path. +fn unsupported() -> anyhow::Error { + symphonia::core::errors::Error::Unsupported( + "audio edit list requires the compatibility decoder", + ) + .into() +} + +/// Read unit-rate ordered edits for the track selected by the content-based demuxer. +pub(super) fn read( + path: &Path, + track_id: u32, + rate: u32, + check: impl FnMut() -> Result<()>, +) -> Result> { + let file = File::open(path)?; + let metadata = file.metadata()?; + ensure!( + metadata.is_file(), + "MP4 timing source is not a regular file" + ); + let mut reader = Reader { + file, + boxes: 0, + check, + }; + let root = reader.children(0, metadata.len())?; + let moov = one(&root, *b"moov")?; + let movie = reader.children(moov.body, moov.end)?; + let movie_scale = reader.timescale(one(&movie, *b"mvhd")?)?; + let mut chosen = None; + for track in movie.iter().filter(|atom| atom.kind == *b"trak") { + let children = reader.children(track.body, track.end)?; + let header = one(&children, *b"tkhd")?; + let offset = reader.timestamp_offset(header)?; + if u32::from_be_bytes(reader.field::<4>(header, offset)?) == track_id { + ensure!(chosen.is_none(), "duplicate MP4 track identifier"); + chosen = Some(children); + } + } + let track = chosen.context("MP4 audio timing track missing")?; + let Some(edit_container) = track.iter().find(|atom| atom.kind == *b"edts") else { + return Ok(None); + }; + ensure!( + track.iter().filter(|atom| atom.kind == *b"edts").count() == 1, + "duplicate MP4 edit container" + ); + let edit_atoms = reader.children(edit_container.body, edit_container.end)?; + let elst = one(&edit_atoms, *b"elst")?; + let media_container = one(&track, *b"mdia")?; + let media = reader.children(media_container.body, media_container.end)?; + let media_scale = reader.timescale(one(&media, *b"mdhd")?)?; + let [version] = reader.field::<1>(elst, 0)?; + ensure!(version <= 1, "invalid MP4 edit version"); + let count = usize::try_from(u32::from_be_bytes(reader.field::<4>(elst, 4)?))?; + ensure!(count <= MAX_EDITS, "MP4 edit budget exceeded"); + let size = if version == 0 { 12 } else { 20 }; + ensure!( + elst.end - elst.body == 8 + u64::try_from(count)? * size, + "invalid MP4 edit table size" + ); + let mut edits = Vec::with_capacity(count); + let mut movie_end = 0_u64; + let mut previous_end = 0_u64; + let mut compatibility = false; + for index in 0..count { + let offset = 8 + u64::try_from(index)? * size; + let (duration, start, rate_offset) = if version == 0 { + ( + u64::from(u32::from_be_bytes(reader.field::<4>(elst, offset)?)), + i64::from(i32::from_be_bytes(reader.field::<4>(elst, offset + 4)?)), + offset + 8, + ) + } else { + ( + u64::from_be_bytes(reader.field::<8>(elst, offset)?), + i64::from_be_bytes(reader.field::<8>(elst, offset + 8)?), + offset + 16, + ) + }; + ensure!(start >= -1, "invalid MP4 edit media time"); + compatibility |= reader.field::<4>(elst, rate_offset)? != [0, 1, 0, 0]; + let old_end = frames(movie_end, movie_scale, rate)?; + movie_end = movie_end + .checked_add(duration) + .context("MP4 movie duration overflow")?; + let duration = frames(movie_end, movie_scale, rate)? - old_end; + if duration == 0 { + continue; + } + if start == -1 { + edits.push(Edit::Silence(duration)); + } else { + let start = frames(u64::try_from(start)?, media_scale, rate)?; + let end = start + .checked_add(duration) + .context("MP4 media duration overflow")?; + compatibility |= start < previous_end; + previous_end = end; + edits.push(Edit::Audio { start, end }); + } + } + if compatibility { + return Err(unsupported()); + } + Ok((!edits.is_empty()).then_some(Presentation { + edits, + next: 0, + position: 0, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Empty edits, gaps, and multiple forward ranges preserve presentation order. + #[test] + fn silence_and_forward_ranges_are_streamed_without_padding() -> Result<()> { + let mut plan = Presentation { + edits: vec![ + Edit::Silence(2), + Edit::Audio { start: 1, end: 3 }, + Edit::Audio { start: 5, end: 6 }, + Edit::Silence(1), + ], + next: 0, + position: 0, + }; + let mut samples = Vec::new(); + let mut visit = |sample| { + samples.push(sample); + Ok(()) + }; + let mut total = 0; + for value in 0_u16..7 { + total += plan.frame(f32::from(value), &mut visit, || Ok(()))?; + } + total += plan.finish(&mut visit, || Ok(()))?; + ensure!( + total == 6 && samples == [0.0, 0.0, 1.0, 2.0, 5.0, 0.0], + "MP4 timeline selection changed" + ); + let mut missing = Presentation { + edits: vec![Edit::Audio { start: 0, end: 2 }], + next: 0, + position: 0, + }; + missing.frame(0.5, &mut |_| Ok(()), || Ok(()))?; + ensure!( + missing.finish(&mut |_| Ok(()), || Ok(())).is_err(), + "missing media was invented" + ); + let mut long = Presentation { + edits: vec![Edit::Silence(u64::MAX)], + next: 0, + position: 0, + }; + ensure!( + long.frame(0.0, &mut |_| Ok(()), || anyhow::bail!("cancelled")) + .is_err(), + "unbounded silence ignored cancellation" + ); + Ok(()) + } + + /// A genuine AAC movie protects framing, versions and bounded edit allocations. + #[test] + fn malformed_movie_timing_is_rejected_before_allocation() -> Result<()> { + let source = include_bytes!("../../tests/fixtures/media/tone.m4a"); + let elst = source + .windows(4) + .position(|bytes| bytes == b"elst") + .context("fixture edit list missing")?; + let directory = tempfile::tempdir()?; + let path = directory.path().join("misnamed.bin"); + for (offset, bytes) in [ + (elst + 8, u32::MAX.to_be_bytes().to_vec()), + (elst + 4, vec![2]), + (elst - 4, u32::MAX.to_be_bytes().to_vec()), + (elst + 16, (-2_i32).to_be_bytes().to_vec()), + ] { + let mut invalid = source.to_vec(); + invalid + .get_mut(offset..offset + bytes.len()) + .context("fixture offset missing")? + .copy_from_slice(&bytes); + std::fs::write(&path, invalid)?; + let error = read(&path, 1, 48_000, || Ok(())) + .err() + .context("invalid edit metadata accepted")?; + ensure!( + !super::super::is_unsupported(&error), + "malformed edits selected compatibility" + ); + } + let mut dwell = source.to_vec(); + dwell + .get_mut(elst + 20..elst + 24) + .context("fixture rate missing")? + .fill(0); + std::fs::write(&path, dwell)?; + let error = read(&path, 1, 48_000, || Ok(())) + .err() + .context("unimplemented dwell was ignored")?; + ensure!( + super::super::is_unsupported(&error), + "valid dwell lost its explicit compatibility path" + ); + ensure!( + read(&path, 1, 48_000, || anyhow::bail!("deadline")).is_err(), + "timing parser ignored deadline" + ); + Ok(()) + } + /// An unsupported first edit must not hide malformed metadata in later entries. + #[test] + fn malformed_edit_after_valid_dwell_cannot_select_compatibility() -> Result<()> { + let source = include_bytes!("../../tests/fixtures/media/tone.m4a"); + let elst = source + .windows(4) + .position(|bytes| bytes == b"elst") + .context("fixture edit list missing")?; + ensure!( + source.get(elst + 8..elst + 12) == Some(&1_u32.to_be_bytes()), + "fixture must have one version-zero edit" + ); + let mut second = source + .get(elst + 12..elst + 24) + .context("fixture entry missing")? + .to_vec(); + second + .get_mut(4..8) + .context("media time missing")? + .copy_from_slice(&(-2_i32).to_be_bytes()); + let mut invalid = source.to_vec(); + invalid + .get_mut(elst + 8..elst + 12) + .context("count missing")? + .copy_from_slice(&2_u32.to_be_bytes()); + invalid + .get_mut(elst + 20..elst + 24) + .context("rate missing")? + .fill(0); + for kind in [b"moov", b"trak", b"edts", b"elst"] { + let kind_offset = source + .windows(4) + .position(|bytes| bytes == kind) + .context("fixture ancestor missing")?; + let field = invalid + .get_mut(kind_offset - 4..kind_offset) + .context("fixture size missing")?; + let size = u32::from_be_bytes(field.try_into()?) + .checked_add(12) + .context("fixture size overflow")?; + field.copy_from_slice(&size.to_be_bytes()); + } + drop(invalid.splice(elst + 24..elst + 24, second)); + let directory = tempfile::tempdir()?; + let path = directory.path().join("dwell-then-invalid.m4a"); + std::fs::write(&path, invalid)?; + let error = read(&path, 1, 48_000, || Ok(())) + .err() + .context("malformed later edit accepted")?; + ensure!( + !super::super::is_unsupported(&error), + "unsupported first edit hid malformed later media time" + ); + Ok(()) + } +} diff --git a/src/media/audio_opus.rs b/src/media/audio_opus.rs new file mode 100644 index 00000000..57065893 --- /dev/null +++ b/src/media/audio_opus.rs @@ -0,0 +1,386 @@ +//! Bounded Opus multistream decoding with RFC 6716 Appendix B framing. +//! +//! Codec reconstruction comes from `opus-pure`. This adapter preserves the +//! container's explicit mapping and padding, including discrete-channel layouts. + +use anyhow::{ensure, Context as _, Result}; +use opus_pure::{OpusDecoder, MAX_PACKET_SAMPLES}; + +/// One decoder per coded mono/stereo stream, with explicit output-channel mapping. +#[derive(Debug)] +pub(super) struct Decoder { + /// Independent state for each coded mono or coupled stereo stream. + streams: Vec, + /// Number of initial streams that each reconstruct two channels. + coupled: usize, + /// Output-channel indices into coded PCM; 255 represents silence. + mapping: Vec, + /// Reused single-stream interleaved reconstruction buffer. + pcm: Vec, + /// Reused ordinary packet after removing self-delimiting framing. + packet: Vec, +} + +impl Decoder { + /// Validate the identification header before allocating decoder state. + pub(super) fn new(header: &[u8], channels: usize) -> Result { + let (streams, coupled, mapping) = Self::layout(header, channels)?; + let streams = (0..streams) + .map(|index| OpusDecoder::new(48_000, if index < coupled { 2 } else { 1 })) + .collect::, _>>()?; + Ok(Self { + streams, + coupled, + mapping, + pcm: Vec::new(), + packet: Vec::new(), + }) + } + + /// Validate metadata without allocating codec reconstruction state during probing. + pub(super) fn validate(header: &[u8], channels: usize) -> Result<()> { + Self::layout(header, channels)?; + Ok(()) + } + + /// Decode the bounded header layout, including arbitrary and silent channel mappings. + fn layout(header: &[u8], channels: usize) -> Result<(usize, usize, Vec)> { + ensure!((1..=255).contains(&channels), "invalid Opus channel count"); + ensure!(header.get(..8) == Some(b"OpusHead"), "invalid Opus header"); + ensure!( + header.get(8).is_some_and(|version| version >> 4 == 0), + "unsupported Opus header version" + ); + ensure!( + header.get(9).copied().map(usize::from) == Some(channels), + "Opus channels disagree with container" + ); + let family = *header.get(18).context("truncated Opus mapping")?; + let (streams, coupled, mapping) = if family == 0 { + ensure!( + (1..=2).contains(&channels), + "invalid single-stream Opus channels" + ); + ( + 1, + channels - 1, + if channels == 1 { vec![0] } else { vec![0, 1] }, + ) + } else { + let streams = usize::from(*header.get(19).context("truncated Opus stream count")?); + let coupled = usize::from(*header.get(20).context("truncated Opus coupled count")?); + let mapping = header + .get(21..21 + channels) + .context("truncated Opus channel map")?; + ensure!( + streams > 0 && coupled <= streams && streams + coupled <= 255, + "invalid Opus stream mapping" + ); + ensure!( + mapping + .iter() + .all(|slot| *slot == 255 || usize::from(*slot) < streams + coupled), + "invalid Opus channel mapping" + ); + if !matches!(family, 1 | 2 | 255) { + return Err(symphonia::core::errors::Error::Unsupported( + "Opus mapping family requires the compatibility decoder", + ) + .into()); + } + ensure!( + family != 1 || channels <= 8, + "invalid surround Opus channel count" + ); + (streams, coupled, mapping.to_vec()) + }; + Ok((streams, coupled, mapping)) + } + + /// Decode every stream and reject differing packet durations before publication. + pub(super) fn decode( + &mut self, + data: &[u8], + output: &mut Vec, + mut check: impl FnMut() -> Result<()>, + ) -> Result { + ensure!(!data.is_empty(), "empty Opus packet"); + let channels = self.mapping.len(); + output.clear(); + output.resize( + MAX_PACKET_SAMPLES + .checked_mul(channels) + .context("Opus buffer overflow")?, + 0.0, + ); + let mut remaining = data; + let mut frames = None; + let count = self.streams.len(); + for (index, decoder) in self.streams.iter_mut().enumerate() { + check()?; + let stream_channels = if index < self.coupled { 2 } else { 1 }; + self.pcm.resize(MAX_PACKET_SAMPLES * stream_channels, 0.0); + let encoded = if index + 1 == count { + remaining + } else { + let consumed = normalize_self_delimited(remaining, &mut self.packet)?; + remaining = remaining + .get(consumed..) + .context("invalid Opus packet extent")?; + &self.packet + }; + let decoded = decoder.decode(encoded, MAX_PACKET_SAMPLES, &mut self.pcm)?; + ensure!( + decoded > 0 && decoded <= MAX_PACKET_SAMPLES, + "invalid decoded Opus duration" + ); + if let Some(expected) = frames { + ensure!(decoded == expected, "Opus streams have different durations"); + } + frames = Some(decoded); + let base = if index < self.coupled { + index * 2 + } else { + index + self.coupled + }; + for (channel, mapped) in self.mapping.iter().copied().enumerate() { + let mapped = usize::from(mapped); + if mapped >= base && mapped < base + stream_channels { + for (target, source) in output + .chunks_exact_mut(channels) + .take(decoded) + .zip(self.pcm.chunks_exact(stream_channels)) + { + *target + .get_mut(channel) + .context("Opus output channel is missing")? = *source + .get(mapped - base) + .context("Opus coded channel is missing")?; + } + } + } + } + let frames = frames.context("Opus packet has no streams")?; + output.truncate(frames * channels); + Ok(frames) + } +} + +/// Remove Appendix B's extra frame-length field, retaining ordinary framing/padding. +fn normalize_self_delimited(data: &[u8], output: &mut Vec) -> Result { + let toc = *data.first().context("empty self-delimited Opus packet")?; + let mut cursor = 1; + let mut padding = 0_usize; + let mut payload = 0_usize; + let (count, cbr) = match toc & 3 { + 0 => (1, false), + 1 => (2, true), + 2 => { + payload = read_size(data, &mut cursor)?; + (2, false) + } + _ => { + let flags = read_byte(data, &mut cursor)?; + let count = usize::from(flags & 63); + ensure!((1..=48).contains(&count), "invalid Opus frame count"); + if flags & 64 != 0 { + loop { + let byte = read_byte(data, &mut cursor)?; + padding = padding + .checked_add(if byte == 255 { 254 } else { usize::from(byte) }) + .context("Opus padding overflow")?; + ensure!(padding <= data.len(), "truncated Opus padding"); + if byte != 255 { + break; + } + } + } + let cbr = flags & 128 == 0; + if !cbr { + for _ in 1..count { + payload = payload + .checked_add(read_size(data, &mut cursor)?) + .context("Opus payload overflow")?; + } + } + (count, cbr) + } + }; + let samples_per_frame = if toc & 128 != 0 { + 120 << ((toc >> 3) & 3) + } else if toc & 0x60 == 0x60 { + if toc & 8 != 0 { + 960 + } else { + 480 + } + } else { + *[480, 960, 1920, 2880] + .get(usize::from((toc >> 3) & 3)) + .context("invalid Opus TOC")? + }; + ensure!( + count * samples_per_frame <= MAX_PACKET_SAMPLES, + "Opus packet exceeds duration limit" + ); + let extra_start = cursor; + let last = read_size(data, &mut cursor)?; + payload = if cbr { + last.checked_mul(count) + } else { + payload.checked_add(last) + } + .context("Opus payload overflow")?; + let end = cursor + .checked_add(payload) + .and_then(|size| size.checked_add(padding)) + .context("Opus packet size overflow")?; + ensure!(end <= data.len(), "truncated self-delimited Opus packet"); + output.clear(); + output.extend_from_slice( + data.get(..extra_start) + .context("invalid Opus header extent")?, + ); + output.extend_from_slice( + data.get(cursor..end) + .context("invalid Opus payload extent")?, + ); + Ok(end) +} + +/// Read one bounded length byte. +fn read_byte(data: &[u8], cursor: &mut usize) -> Result { + let value = *data.get(*cursor).context("truncated Opus packet header")?; + *cursor += 1; + Ok(value) +} + +/// RFC 6716 Section 3.2.1 one- or two-byte frame size, always at most 1275. +fn read_size(data: &[u8], cursor: &mut usize) -> Result { + let first = read_byte(data, cursor)?; + Ok(if first < 252 { + usize::from(first) + } else { + usize::from(first) + 4 * usize::from(read_byte(data, cursor)?) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Retain code 0/1/2/3 framing, VBR/CBR, multi-byte lengths and trailing padding. + #[test] + fn self_delimited_packet_lengths_and_padding_are_exact() -> Result<()> { + for (source, expected, consumed) in [ + (vec![0, 3, 1, 2, 3, 99], vec![0, 1, 2, 3], 5), + (vec![1, 2, 1, 2, 3, 4, 99], vec![1, 1, 2, 3, 4], 6), + (vec![2, 1, 2, 1, 2, 3, 99], vec![2, 1, 1, 2, 3], 6), + (vec![3, 65, 2, 1, 7, 0, 0, 99], vec![3, 65, 2, 7, 0, 0], 7), + (vec![3, 130, 1, 2, 7, 8, 9, 99], vec![3, 130, 1, 7, 8, 9], 7), + ] { + let mut actual = Vec::new(); + ensure!( + normalize_self_delimited(&source, &mut actual)? == consumed, + "packet boundary changed" + ); + ensure!(actual == expected, "ordinary framing changed"); + } + let mut source = vec![0, 252, 0]; + source.extend_from_slice(&[7; 252]); + source.push(99); + let mut output = Vec::new(); + ensure!( + normalize_self_delimited(&source, &mut output)? == 255, + "two-byte frame size changed" + ); + ensure!(output.len() == 253, "length field was retained"); + Ok(()) + } + + /// Compare each mapped channel against a separate libopus reference decoder. + #[test] + fn channel_mapping_padding_and_gain_match_independent_pcm() -> Result<()> { + use opus_pure::{OggOpusReader, Trim}; + use std::fs::File; + use std::path::Path; + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + for name in [ + "opus-surround-distinct", + "opus-surround-padded-gain", + "opus-surround-discrete", + ] { + let mut reader = OggOpusReader::new(File::open(root.join(format!("{name}.opus")))?)?; + let head = reader.head().clone(); + let encoded = std::fs::read(root.join(format!("{name}.opus")))?; + let offset = 27 + usize::from(*encoded.get(26).context("missing fixture lacing")?); + let length = usize::from(*encoded.get(27).context("missing fixture header size")?); + let header = encoded + .get(offset..offset + length) + .context("truncated fixture header")?; + let mut decoder = Decoder::new(header, 6)?; + let mut trim = Trim::new(&head, 48_000, 6)?; + let gain = 10.0_f32.powf(f32::from(head.output_gain_q8) / (256.0 * 20.0)); + let reference = std::fs::read(root.join(format!("{name}-native.f32")))?; + let reference = reference + .as_chunks::<4>() + .0 + .iter() + .map(|bytes| f32::from_le_bytes(*bytes)) + .collect::>(); + let mut output = Vec::new(); + let mut actual = Vec::new(); + for packet in reader.packets() { + let packet = packet?; + decoder.decode(&packet.data, &mut output, || Ok(()))?; + actual.extend( + trim.keep(&packet, &output) + .iter() + .map(|sample| sample * gain), + ); + } + ensure!( + actual.len() == 69_120 && reference.len() == actual.len(), + "duration changed for {name}" + ); + // FFmpeg's libopus wrapper reorders all six-channel output to SMPTE, + // including family 255. The adapter preserves the explicit wire map. + let order = [0, 2, 1, 5, 3, 4]; + for (a, b) in actual + .as_chunks::<6>() + .0 + .iter() + .zip(reference.as_chunks::<6>().0.iter()) + { + for (channel, index) in order.iter().copied().enumerate() { + ensure!( + (a.get(index).context("missing mapped channel")? + - b.get(channel).context("missing reference channel")?) + .abs() + <= 0.000_04, + "channel {channel} changed for {name}" + ); + } + } + } + Ok(()) + } + + /// Missing lengths, impossible padding, frame counts and durations fail closed. + #[test] + fn malformed_multistream_framing_is_rejected() { + for source in [ + vec![], + vec![0], + vec![0, 252], + vec![0, 4, 1], + vec![3, 0], + vec![3, 65, 255], + vec![3, 63], + vec![27, 3, 0], + ] { + assert!(normalize_self_delimited(&source, &mut Vec::new()).is_err()); + } + } +} diff --git a/src/media/convert.rs b/src/media/convert.rs index dcab1452..fe0762aa 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -85,7 +85,7 @@ pub fn convert_file( let action = conversion_action(mime); match action { - ConversionAction::ToWebp => convert_to_webp(input_path, output_dir, file_stem), + ConversionAction::ToWebp => convert_to_webp(input_path, mime, output_dir, file_stem), ConversionAction::ToWebpIfSmaller => { convert_png_if_smaller(input_path, output_dir, file_stem) } @@ -98,7 +98,12 @@ pub fn convert_file( /// /// On codec failure, logs a warning and falls back to copying the original /// file unchanged (so the post still succeeds). -fn convert_to_webp(input: &Path, output_dir: &Path, file_stem: &str) -> Result { +fn convert_to_webp( + input: &Path, + mime: &str, + output_dir: &Path, + file_stem: &str, +) -> Result { let output = output_dir.join(format!("{file_stem}.webp")); let tmp_out = temp_sibling(&output); @@ -132,8 +137,8 @@ fn convert_to_webp(input: &Path, output_dir: &Path, file_stem: &str) -> Result { drop(std::fs::remove_file(&tmp_out)); tracing::warn!("image→webp failed ({:#}); storing original", e); - // Fall back: copy input to its original extension destination - copy_as_is_with_ext(input, output_dir, file_stem, ext_for_original_mime(input)) + // Preserve the validated MIME even for suffixless or misnamed temporary files. + copy_as_is(input, mime, output_dir, file_stem) } } } @@ -166,13 +171,13 @@ fn convert_png_if_smaller( // PNG is already optimal drop(std::fs::remove_file(&tmp_webp)); tracing::debug!("PNG→WebP skipped: webp ({webp_size}B) ≥ png ({original_size}B)"); - copy_as_is_with_ext(input, output_dir, file_stem, "png") + copy_as_is(input, "image/png", output_dir, file_stem) } } Err(e) => { drop(std::fs::remove_file(&tmp_webp)); tracing::warn!("png→webp failed ({:#}); storing original PNG", e); - copy_as_is_with_ext(input, output_dir, file_stem, "png") + copy_as_is(input, "image/png", output_dir, file_stem) } } } @@ -194,16 +199,6 @@ fn copy_as_is( ) } -/// Copy `input` to `output_dir/{file_stem}.{ext}`, returning a `ConversionResult`. -fn copy_as_is_with_ext( - input: &Path, - output_dir: &Path, - file_stem: &str, - ext: &str, -) -> Result { - copy_as_is_with_mime(input, output_dir, file_stem, ext, ext_to_static_mime(ext)) -} - /// Copy input bytes using an explicit canonical MIME type and extension. fn copy_as_is_with_mime( input: &Path, @@ -281,54 +276,6 @@ fn file_size(path: &Path) -> Result { .with_context(|| format!("failed to stat {}", path.display())) } -/// Best-guess extension for a file whose extension we preserved but whose -/// original MIME is no longer in scope. Used only in fallback paths. -fn ext_for_original_mime(path: &Path) -> &'static str { - match path.extension().and_then(|e| e.to_str()) { - Some("jpg" | "jpeg") => "jpg", - Some("png") => "png", - Some("gif") => "gif", - Some("heic") => "heic", - Some("heif") => "heif", - Some("bmp") => "bmp", - Some("tiff" | "tif") => "tiff", - Some("webp") => "webp", - Some("webm") => "webm", - Some("mkv") => "mkv", - Some("svg") => "svg", - Some("pdf") => "pdf", - Some("opus") => "opus", - _ => "bin", - } -} - -/// Map a file extension back to a `'static` MIME string for `ConversionResult`. -fn ext_to_static_mime(ext: &str) -> &'static str { - match ext { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "gif" => "image/gif", - "heic" => "image/heic", - "heif" => "image/heif", - "bmp" => "image/bmp", - "tiff" | "tif" => "image/tiff", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "pdf" => "application/pdf", - "webm" => "video/webm", - "mkv" => "video/x-matroska", - "mp4" => "video/mp4", - "mp3" => "audio/mpeg", - "ogg" => "audio/ogg", - "opus" => "audio/opus", - "flac" => "audio/flac", - "wav" => "audio/wav", - "m4a" => "audio/mp4", - "aac" => "audio/aac", - _ => "application/octet-stream", - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/media/images.rs b/src/media/images.rs index f750cc48..70c3ef43 100644 --- a/src/media/images.rs +++ b/src/media/images.rs @@ -190,7 +190,9 @@ fn gif_loops(input: &Path, budget: &AnimationBudget) -> Result { match decoder.repeat() { gif::Repeat::Infinite => Ok(0), // GIF counts repeats after the first play; WebP counts total plays. - gif::Repeat::Finite(repeats) => Ok(repeats.saturating_add(1)), + gif::Repeat::Finite(repeats) => repeats + .checked_add(1) + .context("GIF loop count cannot be represented by WebP"), } } diff --git a/src/media/images/tests.rs b/src/media/images/tests.rs index 0b690519..054500b0 100644 --- a/src/media/images/tests.rs +++ b/src/media/images/tests.rs @@ -37,7 +37,11 @@ fn gif_webp_preserves_partial_updates_disposal_delays_and_loops() -> Result<()> let dir = tempfile::tempdir()?; let input = dir.path().join("fixture.gif"); let output = dir.path().join("animation.webp"); - for (repeat, expected_loops) in [(gif::Repeat::Infinite, 0), (gif::Repeat::Finite(3), 4)] { + for (repeat, expected_loops) in [ + (gif::Repeat::Infinite, 0), + (gif::Repeat::Finite(3), 4), + (gif::Repeat::Finite(u16::MAX - 1), u32::from(u16::MAX)), + ] { gif_fixture(&input, repeat)?; let expected = GifDecoder::new(BufReader::new(File::open(&input)?))? .into_frames() @@ -82,6 +86,60 @@ fn gif_webp_preserves_partial_updates_disposal_delays_and_loops() -> Result<()> Ok(()) } +/// GIF counts repeats after the first play; WebP's u16 counts total plays. +#[test] +fn unrepresentable_gif_repeat_count_does_not_publish_a_changed_animation() -> Result<()> { + let dir = tempfile::tempdir()?; + let input = dir.path().join("max-repeat.gif"); + let output = dir.path().join("animation.webp"); + gif_fixture(&input, gif::Repeat::Finite(u16::MAX))?; + let error = image_to_webp(&input, &output, None) + .err() + .context("65536 total GIF plays were silently changed to 65535")?; + ensure!( + error.to_string().contains("loop"), + "missing loop diagnostic" + ); + ensure!(!output.exists(), "unrepresentable animation was published"); + Ok(()) +} + +/// Conversion failures must preserve validated MIME, independent of temporary names. +#[test] +fn valid_gif_conversion_fallback_preserves_mime_frames_and_original_bytes() -> Result<()> { + let dir = tempfile::tempdir()?; + for (index, name) in ["upload", "misleading.jpg", "input.GIF"].iter().enumerate() { + let input = dir.path().join(name); + gif_fixture(&input, gif::Repeat::Finite(u16::MAX))?; + let original = std::fs::read(&input)?; + let stem = format!("preserved-{index}"); + let result = crate::media::convert::convert_file(&input, "image/gif", dir.path(), &stem)?; + ensure!(!result.was_converted, "unrepresentable GIF was changed"); + ensure!(result.final_mime == "image/gif", "validated MIME was lost"); + ensure!( + result + .final_path + .extension() + .is_some_and(|ext| ext == "gif"), + "wrong fallback extension" + ); + ensure!( + std::fs::read(&result.final_path)? == original, + "fallback changed GIF bytes" + ); + let decoder = GifDecoder::new(BufReader::new(File::open(&result.final_path)?))?; + ensure!( + decoder.into_frames().collect_frames()?.len() == 4, + "fallback lost animation frames" + ); + ensure!( + !dir.path().join(format!("{stem}.webp")).exists(), + "fallback left partial WebP" + ); + } + Ok(()) +} + #[test] fn transparent_gif_and_existing_webp_scale_without_flattening() -> Result<()> { let dir = tempfile::tempdir()?; diff --git a/src/media/pdf.rs b/src/media/pdf.rs index 5bb69c77..70c6157a 100644 --- a/src/media/pdf.rs +++ b/src/media/pdf.rs @@ -1,12 +1,13 @@ //! Pure-Rust first-page PDF previews with a conservative resource safety gate. //! //! Hayro does not expose an allocation/work budget for arbitrary PDF programs. -//! Until it does, compressed streams and recursive rendering resources use the -//! existing placeholder. Small vector/text documents can be rendered safely. +//! Direct Flate streams are expanded under an aggregate cap and inspected before +//! rendering. Recursive resources and inline images still use the placeholder. use anyhow::{ensure, Context as _, Result}; use hayro::hayro_interpret::InterpreterSettings; -use hayro::hayro_syntax::object::{Array, Dict, MaybeRef, Name, ObjectIdentifier}; +use hayro::hayro_syntax::content::Operator; +use hayro::hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier}; use hayro::hayro_syntax::reader::{Reader, ReaderExt as _}; use hayro::hayro_syntax::Pdf; use hayro::{RenderCache, RenderSettings}; @@ -47,8 +48,7 @@ pub(crate) fn render(input: &Path, output: &Path, max_dim: u32) -> Result<()> { ); let mut bytes = Vec::new(); source.take(MAX_PREVIEW_BYTES + 1).read_to_end(&mut bytes)?; - preflight(&bytes)?; - let source_length = u64::try_from(bytes.len())?; + let source_length = preflight(&bytes)?; let pdf = Pdf::new(bytes).map_err(|error| anyhow::anyhow!("PDF parse failed: {error:?}"))?; ensure!( pdf.len() <= MAX_PREVIEW_OBJECTS, @@ -116,7 +116,13 @@ pub(crate) fn render(input: &Path, output: &Path, max_dim: u32) -> Result<()> { /// /// This gate is deliberately conservative: unsupported previews never reject an /// upload. Name escapes are refused so escaped resource keys cannot bypass it. -fn preflight(bytes: &[u8]) -> Result<()> { +fn preflight(bytes: &[u8]) -> Result { + preflight_syntax(bytes)?; + preflight_page_tree(bytes) +} + +/// Bound syntax before allocating parsed objects or interpreting content. +fn preflight_syntax(bytes: &[u8]) -> Result<()> { ensure!( u64::try_from(bytes.len())? <= MAX_PREVIEW_BYTES, "PDF preview exceeds source budget" @@ -129,8 +135,8 @@ fn preflight(bytes: &[u8]) -> Result<()> { ensure!( !matches!( name, - b"Filter" - | b"F" + b"F" | b"DecodeParms" + | b"DP" | b"Encrypt" | b"XObject" | b"Pattern" @@ -179,7 +185,7 @@ fn preflight(bytes: &[u8]) -> Result<()> { } } } - preflight_page_tree(bytes) + Ok(()) } /// Admit only flat page trees made from bounded, direct dictionary objects. @@ -188,7 +194,8 @@ fn preflight(bytes: &[u8]) -> Result<()> { /// depth/cycle budget. Inspect dictionaries without resolving references first. /// Nested trees, aliases, duplicate revisions and indirect stream lengths use /// the placeholder rather than entering that unbudgeted traversal. -fn preflight_page_tree(bytes: &[u8]) -> Result<()> { +fn preflight_page_tree(bytes: &[u8]) -> Result { + let mut expanded_bytes = u64::try_from(bytes.len())?; let mut reader = Reader::new(bytes); let mut objects = BTreeMap::new(); loop { @@ -227,6 +234,7 @@ fn preflight_page_tree(bytes: &[u8]) -> Result<()> { .context("PDF preview requires direct stream lengths")?, )?; let stream = reader.read_bytes(length).context("truncated PDF stream")?; + preflight_stream(&dict, stream, &mut expanded_bytes)?; ensure!( !stream .split(|byte| byte.is_ascii_whitespace() || b"/[]<>(){}".contains(byte)) @@ -238,6 +246,11 @@ fn preflight_page_tree(bytes: &[u8]) -> Result<()> { .forward_tag(b"endstream") .context("PDF stream length mismatch")?; reader.skip_white_spaces_and_comments(); + } else { + ensure!( + !dict.contains_key(b"Filter"), + "PDF filter is outside a content stream" + ); } reader .forward_tag(b"endobj") @@ -273,6 +286,76 @@ fn preflight_page_tree(bytes: &[u8]) -> Result<()> { ); } } + Ok(expanded_bytes) +} + +/// Expand only simple content streams, within one shared source/work ceiling. +fn preflight_stream(dict: &Dict<'_>, stream: &[u8], expanded_bytes: &mut u64) -> Result<()> { + let decoded; + let program = if dict.contains_key(b"Filter") { + ensure!( + dict.len() == 2 + && matches!(dict.get_raw::>(b"Filter"), + Some(MaybeRef::NotRef(name)) if name.as_ref() == b"FlateDecode"), + "PDF preview requires a single direct Flate content filter" + ); + let limit = MAX_PREVIEW_BYTES + .checked_sub(*expanded_bytes) + .context("PDF expanded content exceeds source budget")?; + let mut inflater = flate2::bufread::ZlibDecoder::new(stream); + let mut buffer = Vec::new(); + (&mut inflater).take(limit + 1).read_to_end(&mut buffer)?; + ensure!( + u64::try_from(buffer.len())? <= limit, + "PDF expanded content exceeds source budget" + ); + ensure!( + inflater.total_in() == u64::try_from(stream.len())?, + "PDF compressed stream has trailing data" + ); + decoded = buffer; + decoded.as_slice() + } else { + stream + }; + *expanded_bytes = expanded_bytes + .checked_add(u64::try_from(program.len())?) + .context("PDF expanded content size overflow")?; + ensure!( + *expanded_bytes <= MAX_PREVIEW_BYTES, + "PDF expanded content exceeds source budget" + ); + preflight_content(program)?; + Ok(()) +} + +/// Inspect actual operators, so `BI` inside a text string is harmless but inline +/// images cannot enter Hayro's unbudgeted image allocation/decode path. +fn preflight_content(program: &[u8]) -> Result<()> { + preflight_syntax(program)?; + let mut reader = Reader::new(program); + while !reader.at_end() { + reader.skip_white_spaces_and_comments(); + if reader.at_end() { + break; + } + if matches!( + reader.peek_byte(), + Some(b'/' | b'.' | b'+' | b'-' | b'0'..=b'9' | b'[' | b'<' | b'(') + ) { + reader + .read_without_context::>() + .context("invalid PDF content operand")?; + } else { + let operator = reader + .read_without_context::>() + .context("invalid PDF content operator")?; + ensure!( + &*operator != b"BI", + "inline PDF images require placeholder preview" + ); + } + } Ok(()) } @@ -285,14 +368,16 @@ mod tests { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); let dir = tempfile::tempdir()?; let output = dir.path().join("page.webp"); - render(&root.join("simple.pdf"), &output, 100)?; - let image = image::open(output)?.into_rgba8(); - ensure!(image.dimensions() == (100, 80), "PDF aspect ratio changed"); - let pixel = image.get_pixel(20, 20); - ensure!( - pixel.0.get(..3) == Some(&[255, 0, 0]), - "first-page red rectangle was not rendered" - ); + for input in ["simple.pdf", "compressed.pdf"] { + render(&root.join(input), &output, 100)?; + let image = image::open(&output)?.into_rgba8(); + ensure!(image.dimensions() == (100, 80), "PDF aspect ratio changed"); + let pixel = image.get_pixel(20, 20); + ensure!( + pixel.0.get(..3) == Some(&[255, 0, 0]), + "first-page red rectangle was not rendered" + ); + } Ok(()) } @@ -355,4 +440,48 @@ mod tests { preflight(&std::fs::read(root.join("simple.pdf"))?)?; Ok(()) } + #[test] + fn compressed_bombs_truncation_and_inline_images_fail_before_rendering() -> Result<()> { + use std::io::Write as _; + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&vec![b' '; usize::try_from(MAX_PREVIEW_BYTES)? + 1])?; + let compressed = encoder.finish()?; + let mut pdf = format!( + "%PDF-1.4\n1 0 obj << /Length {} /Filter /FlateDecode >> stream\n", + compressed.len() + ) + .into_bytes(); + pdf.extend_from_slice(&compressed); + pdf.extend_from_slice(b"\nendstream endobj\nxref"); + ensure!( + preflight(&pdf).is_err(), + "PDF decompression bomb was admitted" + ); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let valid = std::fs::read(root.join("compressed.pdf"))?; + let mut truncated = valid; + let offset = truncated + .windows(7) + .position(|bytes| bytes == b"stream\n") + .context("fixture content stream missing")? + + 7; + *truncated + .get_mut(offset + 27) + .context("fixture zlib checksum missing")? ^= 0xff; + ensure!( + preflight(&truncated).is_err(), + "invalid zlib checksum was admitted" + ); + ensure!( + preflight_content(b"BI /W 999999 /H 999999 /BPC 8 ID x EI").is_err(), + "inline image entered unbudgeted renderer" + ); + preflight_content(b"BT (BI is text) Tj ET")?; + ensure!( + preflight_content(b"9999999999 0 m").is_err(), + "decoded numeric budget was bypassed" + ); + Ok(()) + } } diff --git a/src/media/probe.rs b/src/media/probe.rs index 2a02abc9..045b711a 100644 --- a/src/media/probe.rs +++ b/src/media/probe.rs @@ -163,6 +163,43 @@ impl symphonia::core::io::MediaSource for ContainerSource { } } +/// Supply bounded Opus header/packet reads to the standalone Ogg decoder. +pub(crate) fn open_discrete_opus_source( + path: &Path, + started: Instant, + timeout: Duration, + cancel: &CancellationToken, +) -> Result<(impl std::io::Read + use<>, Arc)> { + let file = File::open(path)?; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "Opus source is not a regular file"); + let window = Arc::new(ContainerReadBudget::new()); + // Bound OpusTags strings/counts before packet-mode's separate ceiling. + window.limit.store(1024 * 1024, Ordering::Relaxed); + Ok(( + ContainerSource { + length: metadata.len(), + file, + started, + timeout, + read_bytes: 0, + window: Arc::clone(&window), + cancel: Some(cancel.clone()), + }, + window, + )) +} + +/// Inspect every page of a discrete Opus stream that Symphonia cannot recognize. +pub(crate) fn discrete_opus_header( + path: &Path, + started: Instant, + timeout: Duration, + cancel: Option<&CancellationToken>, +) -> Result>> { + speex::discrete_opus_header(path, started, timeout, cancel) +} + /// Open a format reader using content signatures, never a client extension. pub(crate) fn open_format(path: &Path) -> Result> { open_bounded_format( @@ -237,6 +274,28 @@ pub fn inspect(path: &Path) -> Result { audio_codec: Some("speex"), }); } + if let Some(header) = discrete_opus_header( + path, + Instant::now(), + Duration::from_secs(crate::config::ffmpeg_timeout_secs()), + None, + )? { + // The same bounded Rust reader validates OpusHead/OpusTags without PCM decoding. + let cancel = CancellationToken::new(); + let (source, _) = open_discrete_opus_source( + path, + Instant::now(), + Duration::from_secs(crate::config::ffmpeg_timeout_secs()), + &cancel, + )?; + let reader = opus_pure::OggOpusReader::new(source)?; + super::audio::validate_discrete_opus_header(&header, reader.head())?; + return Ok(MediaInfo { + kind: StreamKind::AudioOnly, + video_codec: None, + audio_codec: Some("opus"), + }); + } let format = open_format(path)?; let mut video_codec = None; let mut audio_codec = None; @@ -267,7 +326,7 @@ pub fn inspect(path: &Path) -> Result { }) } -/// Validate single-stream Ogg Speex before selecting its unavailable decoder. +/// Validate every logical Speex stream before selecting its unavailable decoder. pub(crate) fn is_speex( path: &Path, started: Instant, @@ -443,6 +502,66 @@ mod tests { Ok(()) } + #[test] + fn every_speex_logical_stream_must_end_and_pass_crc() -> Result<()> { + let original = include_bytes!("../../tests/fixtures/media/speex-chained.spx"); + let directory = tempfile::tempdir()?; + let path = directory.path().join("bad.spx"); + let mut corrupted = original.to_vec(); + *corrupted.last_mut().context("empty chained fixture")? ^= 1; + for bytes in [ + corrupted.as_slice(), + original + .get(..original.len() - 1) + .context("empty fixture")?, + ] { + std::fs::write(&path, bytes)?; + let error = inspect(&path) + .err() + .context("invalid second stream was accepted")?; + ensure!( + !super::super::audio::is_unsupported(&error), + "invalid second stream selected fallback" + ); + } + let cancel = CancellationToken::new(); + cancel.cancel(); + ensure!( + is_speex(&path, Instant::now(), Duration::from_secs(1), Some(&cancel)).is_err(), + "cancelled validation continued" + ); + ensure!( + is_speex(&path, Instant::now(), Duration::ZERO, None).is_err(), + "expired validation continued" + ); + Ok(()) + } + + #[test] + fn discrete_opus_crc_and_truncation_fail_closed() -> Result<()> { + let original = include_bytes!("../../tests/fixtures/media/opus-surround-discrete.opus"); + let directory = tempfile::tempdir()?; + let path = directory.path().join("misleading.jpg"); + let mut corrupt = original.to_vec(); + *corrupt.last_mut().context("empty Opus fixture")? ^= 1; + for bytes in [ + corrupt.as_slice(), + original + .get(..original.len() - 1) + .context("empty Opus fixture")?, + ] { + std::fs::write(&path, bytes)?; + let error = inspect(&path) + .err() + .context("invalid discrete Opus accepted")?; + ensure!( + !super::super::audio::is_unsupported(&error), + "invalid Opus selected compatibility" + ); + } + Ok(()) + } + #[test] fn fixture_metadata_preserves_verified_stream_types_and_codecs() -> Result<()> { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); @@ -455,7 +574,15 @@ mod tests { ("he-aac.m4a", StreamKind::AudioOnly, "aac"), ("ac3.m4a", StreamKind::AudioOnly, "ac3"), ("surround.opus", StreamKind::AudioOnly, "opus"), + ("opus-surround-discrete.opus", StreamKind::AudioOnly, "opus"), ("tone.spx", StreamKind::AudioOnly, "speex"), + ("speex-chained.spx", StreamKind::AudioOnly, "speex"), + ("speex-multiplexed.spx", StreamKind::AudioOnly, "speex"), + ( + "speex-chained-multiplexed.spx", + StreamKind::AudioOnly, + "speex", + ), ("audio.mkv", StreamKind::AudioOnly, "flac"), ("multiple-streams.mp4", StreamKind::Video, "h264"), ("audio.webm", StreamKind::AudioOnly, "opus"), diff --git a/src/media/probe/speex.rs b/src/media/probe/speex.rs index 04bbbc74..514cd2d0 100644 --- a/src/media/probe/speex.rs +++ b/src/media/probe/speex.rs @@ -1,6 +1,7 @@ -//! Bounded Ogg validation for the Speex compatibility path. +//! Bounded Ogg validation for Speex and discrete Opus logical streams. use anyhow::{ensure, Context as _, Result}; +use std::collections::BTreeMap; use std::fs::File; use std::io::{Read as _, Seek as _}; use std::path::Path; @@ -9,6 +10,30 @@ use tokio_util::sync::CancellationToken; /// Match the maximum supported audio upload size without retaining the clip. const MAX_CONTAINER_BYTES: u64 = 512 * 1024 * 1024; +/// Bound metadata retained for simultaneous or chained logical streams. +const MAX_LOGICAL_STREAMS: usize = 64; + +/// Validation state for one independently numbered Ogg logical stream. +#[derive(Default)] +struct LogicalStream { + /// Next page number required for this serial. + sequence: u32, + /// Completed headers and audio packets seen so far. + packets: u64, + /// Whether the last page left an unfinished packet. + continued: bool, + /// Validated optional header count from Speex identification. + extra_headers: u32, + /// Whether an authenticated, complete EOS page was observed. + ended: bool, +} + +impl LogicalStream { + /// Finished chains and newly opened BOS headers permit another grouped BOS page. + const fn permits_bos(&self) -> bool { + self.ended || (self.sequence == 1 && self.packets == 1) + } +} /// Validate every page before classifying an otherwise uncovered audio stream. pub(super) fn inspect( @@ -47,39 +72,154 @@ pub(super) fn inspect( metadata.len() <= MAX_CONTAINER_BYTES, "Speex input exceeds source budget" ); - let extra_headers = validate_identification(&first)?; + let mut streams = BTreeMap::<[u8; 4], LogicalStream>::new(); + let mut page = first; + loop { + check()?; + let serial: [u8; 4] = page + .header + .get(14..18) + .context("missing Ogg serial")? + .try_into()?; + if !streams.contains_key(&serial) { + ensure!( + streams.values().all(|stream| stream.ended) + || streams.values().all(LogicalStream::permits_bos), + "Speex BOS pages are not grouped at a chain boundary" + ); + ensure!( + streams.len() < MAX_LOGICAL_STREAMS, + "Speex logical stream budget exceeded" + ); + ensure!( + page.body.get(..8) == Some(b"Speex "), + "mixed-codec Speex container is not validated" + ); + let extra_headers = validate_identification(&page)?; + streams.insert( + serial, + LogicalStream { + extra_headers, + ..LogicalStream::default() + }, + ); + } + let stream = streams + .get_mut(&serial) + .context("missing Speex stream state")?; + ensure!(!stream.ended, "Ogg pages follow logical stream end"); + page.validate(&serial, stream.sequence, stream.continued)?; + stream.packets = stream + .packets + .checked_add(u64::try_from( + page.lacing.iter().filter(|size| **size < 255).count(), + )?) + .context("Speex packet count overflow")?; + stream.continued = page.lacing.last() == Some(&255); + let flags = *page.header.get(5).context("missing Ogg flags")?; + if flags & 4 != 0 { + ensure!(!stream.continued, "incomplete Speex packet"); + ensure!( + stream.packets > 2 + u64::from(stream.extra_headers), + "Speex has no audio packets" + ); + stream.ended = true; + } + if remaining == 0 { + ensure!( + streams.values().all(|stream| stream.ended), + "incomplete Speex container" + ); + break; + } + stream.sequence = stream + .sequence + .checked_add(1) + .context("Ogg sequence overflow")?; + page = Page::read(&mut file, &mut remaining)?; + } + check()?; + Ok(true) +} + +/// Validate an otherwise unrecognized discrete Opus stream before declaring its kind. +pub(super) fn discrete_opus_header( + path: &Path, + started: Instant, + timeout: Duration, + cancel: Option<&CancellationToken>, +) -> Result>> { + let check = || -> Result<()> { + ensure!( + !cancel.is_some_and(CancellationToken::is_cancelled), + "Opus inspection cancelled" + ); + ensure!(started.elapsed() < timeout, "Opus inspection timed out"); + Ok(()) + }; + check()?; + let mut file = File::open(path)?; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "Opus source is not a regular file"); + if metadata.len() < 27 { + return Ok(None); + } + let mut prefix = [0; 4]; + file.read_exact(&mut prefix)?; + if &prefix != b"OggS" { + return Ok(None); + } + file.rewind()?; + let mut remaining = metadata.len(); + let first = Page::read(&mut file, &mut remaining)?; + if first.body.get(..8) != Some(b"OpusHead") || first.body.get(18) != Some(&255) { + return Ok(None); + } + ensure!( + metadata.len() <= MAX_CONTAINER_BYTES, + "Opus input exceeds source budget" + ); + ensure!( + first.lacing.iter().filter(|size| **size < 255).count() == 1 + && first.lacing.last() != Some(&255), + "invalid Opus identification framing" + ); + let header = first.body.clone(); let serial = first .header .get(14..18) - .context("missing Ogg serial")? + .context("missing Opus serial")? .to_vec(); + let mut page = first; let mut sequence = 0_u32; - let mut packets = 0_u64; let mut continued = false; - let mut page = first; + let mut packets = 0_u64; loop { check()?; page.validate(&serial, sequence, continued)?; - packets += u64::try_from(page.lacing.iter().filter(|size| **size < 255).count())?; + packets = packets + .checked_add(u64::try_from( + page.lacing.iter().filter(|size| **size < 255).count(), + )?) + .context("Opus packet count overflow")?; continued = page.lacing.last() == Some(&255); - let flags = *page.header.get(5).context("missing Ogg flags")?; + let flags = *page.header.get(5).context("missing Opus flags")?; if remaining == 0 { - ensure!(flags & 4 != 0 && !continued, "incomplete Speex container"); ensure!( - packets > 2 + u64::from(extra_headers), - "Speex has no audio packets" + flags & 4 != 0 && !continued && packets > 2, + "incomplete Opus stream" ); break; } ensure!( flags & 4 == 0, - "chained Speex containers require separate validation" + "chained discrete Opus requires separate decoder states" ); - sequence = sequence.checked_add(1).context("Ogg sequence overflow")?; + sequence = sequence.checked_add(1).context("Opus sequence overflow")?; page = Page::read(&mut file, &mut remaining)?; } check()?; - Ok(true) + Ok(Some(header)) } /// One Ogg page, with at most 255 lacing bytes and 65,025 payload bytes. @@ -125,7 +265,7 @@ impl Page { && (flags & 2 != 0) == (sequence == 0) && self.header.get(14..18) == Some(serial) && self.header.get(18..22) == Some(sequence.to_le_bytes().as_slice()), - "invalid Ogg Speex page sequence" + "invalid Ogg page sequence" ); let expected = u32::from_le_bytes( self.header @@ -155,7 +295,7 @@ impl Page { } crc }); - ensure!(actual == expected, "invalid Ogg Speex page checksum"); + ensure!(actual == expected, "invalid Ogg page checksum"); Ok(()) } } diff --git a/src/media/thumbnail.rs b/src/media/thumbnail.rs index 62b28e4d..e3aa31b9 100644 --- a/src/media/thumbnail.rs +++ b/src/media/thumbnail.rs @@ -422,7 +422,7 @@ mod tests { fn valid_unsupported_pdf_retains_upload_and_uses_svg_preview() -> Result<()> { let dir = tempfile::tempdir()?; let source = - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media/compressed.pdf"); + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media/inline-image.pdf"); let before = std::fs::read(&source)?; let output = dir.path().join("page.webp"); let result = generate_thumbnail(&source, "application/pdf", &output, 100, false, false)?; diff --git a/src/tls/acme.rs b/src/tls/acme.rs index 68339aa1..95f6e437 100644 --- a/src/tls/acme.rs +++ b/src/tls/acme.rs @@ -12,7 +12,7 @@ use std::os::unix::fs::PermissionsExt as _; // Public entry point // --------------------------------------------------------------------------- /// Configure Let's Encrypt certificate provisioning via rustls-acme and -/// return a [`TlsAcceptor`] that will serve valid certificates once the ACME +/// return a [`tokio_rustls::TlsAcceptor`] that will serve valid certificates once the ACME /// challenge has completed. /// /// A background task is spawned to run the ACME event loop, which handles: diff --git a/src/tls/mod.rs b/src/tls/mod.rs index 3c36f28b..596471ee 100644 --- a/src/tls/mod.rs +++ b/src/tls/mod.rs @@ -15,7 +15,7 @@ use crate::{ }; /// A TLS acceptor that is either a static [`TlsAcceptor`] (manual cert or -/// self-signed) or an [`AcmeAcceptor`] (Let's Encrypt / rustls-acme). +/// self-signed) or an [`rustls_acme::AcmeAcceptor`] (Let's Encrypt / rustls-acme). /// /// The ACME variant cannot be represented as a plain [`TlsAcceptor`] because /// the underlying certificate is rotated dynamically by the background renewal @@ -60,7 +60,7 @@ impl fmt::Debug for Acceptor { /// /// # Errors /// -/// Returns [`crate::AppError::Tls`] if: +/// Returns [`crate::error::AppError::Tls`] if: /// - A manual certificate path cannot be read or parsed. /// - The ACME config is invalid (empty domain list, IP address as domain, etc.). /// - The ACME cache directory cannot be created. diff --git a/src/tls/self_signed.rs b/src/tls/self_signed.rs index 490f56e7..5dd980fe 100644 --- a/src/tls/self_signed.rs +++ b/src/tls/self_signed.rs @@ -30,7 +30,7 @@ const CERT_SANS: &[&str] = &["localhost", "127.0.0.1", "::1"]; /// /// # Errors /// -/// Returns [`AppError::Tls`] (wrapped in [`crate::Result`]) if directory +/// Returns [`AppError::Tls`] (wrapped in [`crate::error::Result`]) if directory /// creation fails, certificate/key generation fails, the private files cannot /// be written, or the PEM files cannot be loaded into a `TlsAcceptor`. pub(super) fn generate_or_load(data_dir: &Path) -> Result<(Arc, Arc)> { @@ -140,7 +140,7 @@ fn build_cert_params() -> Result { Ok(params) } -/// Decide the correct [`SanType`] for a raw string: IPv4/6 literals become +/// Decide the correct [`rcgen::SanType`] for a raw string: IPv4/6 literals become /// `IpAddress`, everything else becomes `DnsName`. fn san_for(s: &str) -> Result { if let Ok(address) = s.parse::() { diff --git a/src/workers/mod.rs b/src/workers/mod.rs index 2205a04f..c6a81a07 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -3164,22 +3164,38 @@ mod tests { let dir = tempfile::tempdir()?; let output = dir.path().join("wave.png"); let cancel = tokio_util::sync::CancellationToken::new(); - anyhow::ensure!( - super::render_waveform_with_compatibility( - &root.join("tone.opus"), - &output, - false, - &cancel - ) - .await?, - "common audio requires a tool" - ); - anyhow::ensure!( - image::open(&output)?.width() == crate::config::CONFIG.thumb_size, - "waveform width changed" - ); - std::fs::remove_file(&output)?; - for file in ["ac3.m4a", "surround.opus", "he-aac.m4a", "tone.spx"] { + for file in [ + "tone.opus", + "surround.opus", + "opus-surround-distinct.opus", + "opus-surround-padded-gain.opus", + "opus-surround-discrete.opus", + ] { + anyhow::ensure!( + super::render_waveform_with_compatibility( + &root.join(file), + &output, + false, + &cancel + ) + .await?, + "common audio {file} requires a tool" + ); + anyhow::ensure!( + image::open(&output)?.width() == crate::config::CONFIG.thumb_size, + "waveform width changed" + ); + std::fs::remove_file(&output)?; + } + for file in [ + "ac3.m4a", + "he-aac.m4a", + "he-aac-inband.aac", + "tone.spx", + "speex-chained.spx", + "speex-multiplexed.spx", + "speex-chained-multiplexed.spx", + ] { anyhow::ensure!( !super::render_waveform_with_compatibility( &root.join(file), diff --git a/tests/e2e/non-video-media.spec.ts b/tests/e2e/non-video-media.spec.ts index e70d5351..d1c8937c 100644 --- a/tests/e2e/non-video-media.spec.ts +++ b/tests/e2e/non-video-media.spec.ts @@ -5,7 +5,7 @@ import { createThread, expect, expectSafePage, test } from './helpers'; test('common audio waveforms finish in the worker without FFmpeg', async ({ page, app }) => { test.setTimeout(180_000); const media = path.resolve(__dirname, '../fixtures/media'); - for (const file of ['tone.mp3', 'tone.flac', 'tone.wav', 'tone.ogg', 'tone.aac', 'tone.m4a', 'tone-alac.m4a', 'tone.opus', 'speech-mode.opus', 'audio.webm']) { + for (const file of ['tone.mp3', 'tone.flac', 'tone.wav', 'tone.ogg', 'tone.aac', 'tone.m4a', 'tone-alac.m4a', 'tone.opus', 'speech-mode.opus', 'surround.opus', 'opus-surround-distinct.opus', 'opus-surround-padded-gain.opus', 'opus-surround-discrete.opus', 'audio.webm']) { await createThread(page, app, 'aud', { subject: `Rust waveform ${file}`, body: 'common audio decoder and atomic waveform job', diff --git a/tests/fixtures/media/README.md b/tests/fixtures/media/README.md index 1a877cf9..12c276ae 100644 --- a/tests/fixtures/media/README.md +++ b/tests/fixtures/media/README.md @@ -2,11 +2,11 @@ The tone and video fixtures were generated locally with FFmpeg 9.0.1. They contain only a 440 Hz sine wave or the generated `testsrc2` pattern; there is no third-party recording. Fixture generation may use external tools; application tests and common-codec processing do not. -Audio input: `-f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.12 -ac 2`. Outputs: PCM16 WAV, MP3, FLAC, native Vorbis (`-c:a vorbis -strict experimental`), libopus Ogg and WebM, AAC ADTS and M4A, FLAC Matroska. `speech-mode.opus` uses mono `-application voip -b:a 12k`; `surround.opus` duplicates the tone into a 5.1 pan and uses Opus mapping family 1. These protect pure-Rust SILK/voice decoding and the compatibility fallback for standard Opus multistream packets. `ac3.m4a` uses `-c:a ac3` and protects the explicitly approved uncovered-codec fallback. +Audio input: `-f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.12 -ac 2`. Outputs: PCM16 WAV, MP3, FLAC, native Vorbis (`-c:a vorbis -strict experimental`), libopus Ogg and WebM, AAC ADTS and M4A, FLAC Matroska. `speech-mode.opus` uses mono `-application voip -b:a 12k`; `surround.opus` duplicates the tone into a 5.1 pan and uses Opus mapping family 1. These protect pure-Rust SILK/voice decoding and standard Opus multistream decoding, without external tools. `ac3.m4a` uses `-c:a ac3` and protects the explicitly approved uncovered-codec fallback. Video input: `-f lavfi -i testsrc2=size=32x24:rate=10:duration=0.2`. MP4/Matroska use libx264; WebM uses libvpx-vp9. Their track/codec classifications were compared with the original FFprobe backend before removing its process wrappers. -`simple.pdf` is an original two-page 100 × 80 vector document: a red rectangle on page one and a blue rectangle on page two. `compressed.pdf` is an original one-page red rectangle with a zlib-compressed content stream. The latter verifies that a valid document outside the conservative preview safety subset retains its original download and gets an SVG preview. +`simple.pdf` is an original two-page 100 × 80 vector document: a red rectangle on page one and a blue rectangle on page two. `compressed.pdf` is an original one-page red rectangle with a zlib-compressed content stream. Both verify a rendered first-page preview and preservation of the original PDF download. Compressed content is expanded under an aggregate byte cap before interpreting its operators. `photo.heic` is the generated 2048 × 1536 tiled test scene from [heic-rs](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), encoded by Apple's `sips`; source pixels are generated by its Python script. It is MIT OR Apache-2.0; both license files are included as `photo-LICENSE-*`. @@ -15,3 +15,15 @@ Video input: `-f lavfi -i testsrc2=size=32x24:rate=10:duration=0.2`. MP4/Matrosk `phone-hdr.heic` is an actual iPhone HDR photograph from [John Charankattu's apple-hdr-heic](https://github.com/johncf/apple-hdr-heic), obtained from [the same pinned fixture corpus](https://github.com/imazen/heic/tree/c45113e442c485bb9b4ea37ad53e4d8e82132205/testdata/apple-hdr). Its MIT license is included as `phone-hdr-LICENSE`. The primary SDR picture is decoded; HDR gain-map rendering is outside RustChan's existing thumbnail behaviour. Final-verification fixtures: `tone-alac.m4a` encodes the generated PCM tone with ALAC; `multiple-streams.mp4` remuxes the generated H.264 video plus AAC and MP3 tracks. `he-aac.m4a` uses the macOS `aac_at` encoder, profile 4, at 48 kbit/s and has backward-compatible SBR signaling (FFprobe reports HE-AAC). `tone.spx` uses the optional Speex 1.2.1 command-line encoder on a 16 kHz mono resampling of the tone. The Speex source was downloaded from the official Xiph distribution and built only in a temporary directory; no Speex application dependency was added. `text.pdf` is an original 100 × 80 document displaying “RustChan” in the standard Helvetica font, with an uncompressed content stream and a flat page tree. + +The `opus-surround-distinct.opus` fixture contains six generated sine waves at 233, 331, 449, 71, 557 and 661 Hz, at 48 kHz for 0.24 seconds, encoded with libopus mapping family 1. `opus-surround-padded-gain.opus` adds valid RFC 6716 Appendix B padding to every coded stream and declares +6 dB header gain. `opus-surround-discrete.opus` declares mapping family 255 with a reversed explicit coded-channel map. Each Ogg page checksum and lacing table is regenerated. The matching `-native.f32` files are interleaved little-endian f32 samples decoded by the independent libopus decoder through FFmpeg 9.0.1. They contain 11,520 frames with six channels; production and regression tests do not invoke FFmpeg to read them. + +`speex-chained.spx` concatenates two independently numbered copies of the generated tone. `speex-multiplexed.spx` interleaves their corresponding pages, with both BOS headers first. The second logical stream has a distinct serial and recomputed CRCs. These verify per-stream sequence/continuation/EOS validation. Mixed-codec Ogg containers remain outside this validator's classification contract. + +`aac-lc-native.f32` is FFmpeg 9.0.1's two-channel, 48 kHz AAC-LC decode of the existing generated `tone.m4a`. Its 5,760 presentation frames exclude the encoder delay and tail padding described by that movie's edit list. The reference protects waveform timing independently of Symphonia's raw packet frame count. + +`he-aac-inband.aac` is the same original macOS HE-AAC tone remuxed into ADTS with in-band SBR. Independent decoding yields 12,288 stereo frames at 48 kHz. The patched AAC decoder explicitly reports missing SBR reconstruction; it must never publish the 6,144 core-only frames at 24 kHz as a successful Rust decode. + +`inline-image.pdf` is an original 100 × 80 page with one red RGB pixel displayed as a 60 × 60 inline image. It verifies that valid input outside the bounded renderer subset keeps its original download and receives the deterministic SVG preview. + +`speex-chained-multiplexed.spx` joins two complete multiplexed groups with four distinct serials and recomputed CRCs. Completed logical streams must not block the next chain’s grouped BOS pages. diff --git a/tests/fixtures/media/aac-lc-native.f32 b/tests/fixtures/media/aac-lc-native.f32 new file mode 100644 index 0000000000000000000000000000000000000000..bddf087c08754f03b81025b02b1fd709a04c20b5 GIT binary patch literal 46080 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¨rij^ zG8TOvitD}&#mv1Eaj4fsbatAKlO3mHdDT+PE?dg7^qaBo{LT1P*8`RGJ+Nl7FOD&1 znZUXb^pu96jnh$7-*gm*Cnw;YlmvV`Hw}#!rs4Jd*=!wWX)#Bw*EWgO^Y44q-n7BoXO>9k7^pUZes0=fP$*>`w zgQyt{S`3?g5ePN7VUl4s81>e4mg6Sd?Xa^HG_;N1v zYMToQ?W^D%lVR-wo94T~>Ia_iro|IdF9g6YHY<}?9}XSN6Vq7eH7yp-OiPBbX2~#0 zkpbPCGNA2vE@(1SrWJvw8Ov&DmBAb4q*o=(VBXEDfhsb%+25oI` zC}?(viFe&$!i^0u`0@t0(`7MxXToAI4HUupAQ7zEIRTtqCP3h2P1t^26L#fwgBb~TV3B|`I#aP#DGmG2%f{g|v$0Rt0-T~$fCKz$)aC-)pJWRruj4?m! zUXQ^^HsRQ69gb1U0`QP+02*H3kGrq#$6dzGm@(cNJua`rbJthmow_-A=GYvZ)om&+ z=rR=_m5)TN(vj$ORvjmtR>vjHU+9YaUueUstMtR>t2Aj!5j`-eh`v-mNPqM>NGCoX zOEZ3tr9Wd!6*J9C6~T%54Ubgg8(M!Ah~Byvh}y5~lDWrpNqdJUF*Nid|FD0=Amc)^ zVCfZNu!y}c6ub*cLEf_xlvFEW{=;hc-dYU@MvI|xgcxpIl7QPq30!QE z!sdrkc(R7w$Jp%k{+TkEGK1AdqNLC>SPCD^B&<#&fro6JZCF(cPWP(d1v`trJ6{g; zbUDUR_tbTiq3dUTNU+ zRt>a>9gnLr$D^#;5;xab;uE7qm@#${j!@cwV>{R5Z!LE$9pR3Xb{xSv_aoT6D;NWI z2jf#w6wbGd!UOT~m>L_8v$v<>t{tgJj%VS3lUcYaHXp^&`KZ}ajH~YzWWo3v7ri;7uBMQO)b7>Gct6c1g#~kk0zC3>tPwTd&|&3EW-m*8Fnm_;m)NjGqO^O z*OyAsu#wGED_9LBv<8nx*5HEEl{n#KC7yOILv<$3uL!p@&(!iTq<0=(RmsF;^-P=@ znv6<}Bs>;J2gRaOuLun29f9Tp1JQJFARck^!YU^(9Dmyd9d5ed>HVwG)N?hi-8K)W zyUoKXQWJDln4p`(Xq>ZtG~PJe7booOi}?oM=^wN2G+lgy=3lu%&n1=8goIN1CBv6q zKHy6u#~RYzYKGJrWQq%GWQvdf-fP^mtF>`-mo!nDYMN-QyAIJv(;;6!?It5%?zY&(_%ljY^0gSP5g^R>Q)N)gWQD5L;FgDSE^9 zspl+H{8tK>I;CLjBZG$lGEidq{W@0}oDj46f29;&IZI&Z773gPs)h21S~#Ls4Yhr$ zVYpHS4ER$HuN+HYxKjzt5EVcft9L$&%!YSi*-*DM4gBoVpnF^bn8znT`>La`WW`Z9 zl^+5#3qzn+rw_#c@`08|yI^?hF3A0`2`+!%1o^X;LbBCTnEenyd>7#6zXss@$N;vi z912rc4~6gZl|grbGMtTiPWB`|C$sM%d3_DZIrk*ecS{n9{=9~)>9&?s?^Py?1C`0Y zN=rold{`{%rT3@lXXKxzlY5^lu6jRLygEFW{^vHA*4IVSc5x&Rw4MdI4Q@%WwD{3!+hd`!Ws3$hRxGp&3qWA3>YW0!5k z=s3O{t;Ut(;}unSZDkb>d|!hdA8YW%PciQMA;$i!SKZ($#Xw+ZM&{&xw%!i1dj3!u zO4*)Przgd>(Ng@CE=JW9migaTgHQL@pnqc}{ybKRk3Gt8(C#wyPb$Kq*dp}R&%<#O z^6=)gOq7^s;{4;uxaMRszPJ{P7jDGjZ^sCfZH_>j1A#1`8Hgw59Kh@I4`9a)SA2Kh z6(2^e!C}#B&@*;EHb>6K;ccc^^u!d+^T*(i!ZFy|+7D%~`=Q3{U-Xvf7u9|E4}Ed! zAG+#X1#P)jL2CvD(s5%0>Ft|S>7t8M>8pOH6sEsUDkh3HHEGvuY?_}QAu39WVDlG4 zNLMn1tiQOOJioe~Tr|xlE3L9gWzad&KlmIen%YiYFsH&*!9Q3P_62A|xVJXAcNoFY zE|X#Y{h8q1IujB#?BJ!U9kixyf%A!5V8`mcV6bT~Owsp;)QSEOBnks1=H<<3=y5$7 z(my1^{`N%nE~|7nl#&j<;vDdi<-puI?43BX3gO1GQgB~f3d3$xK*se7&}gWJD~f8E zqRQr7)WqDM zw#C(Ocu_TkFR1|ar4?X&lC87TCGaY*0ERH;pR*zDQ#N=UPlH1z(qOP!B2=p+f@;fA znBII8w2eZc&Lk8<2Kupgiui$d|K0Gm>u$KC=K#~kJFvgcGBAo=2GJHXpncj5a9d~y zV`myd*|}lx`TQ^-v?mmt?g=FxugLa2ugLS#6GSWP1lhYdm297vN|wFZNRI#9NQkc* zNk~#7o}-*as_M=nlY*;FLw8R#Neh%anb9F3hp;||WCI=8~;;D5sCxea1kvPDc) zRyWg_mCe*)c^CZ8whR7^9Ef&N0}aE`P0-kIll=I<69_iqcPc?M&zjlsA$Bog}tN8%HQc=WK3$C%1g z99xx&i(|7<6rYXZodvk!Bk4jem*Rp8r8sm#73%6%q3O$7?08*^Jw`R)Oq~WyKG}%2 zCmK=rUNi2!-Hem}cU}=PQ@SV4q&lNB?L?>)A5t&!gPs(NEG0OBbXt$vsV)yTg4{t{6v}LFqzYGUw&BykF`6zfl18;wy zfg5o=nn}jv?(c)}O6MTlw6u#4@bBVJHLi12&Fj3owundd6!A@)qIh{g6kpS1&hMWx z=d&)HQG6*nqlmkc(JnfY(LVD2e!*GgFoD^EA*{l62n%`P#g^82FqvrBf-mYB>Xr!1)jH0flURLFe29yl&&qJ?5jl(NAv0= zuXU6)ume7f*#XI!`ynHBKZMmp!{EATD4m-G<_nTwvv&rJCiDj9frDBe^dxQJbz!@B#`Fl1Fd;M#iF;Vc3qdWxFBm3-0xJH;R;>MBXEUqM<%(ub_Fkiyj& zbZ0cDK4ggm?29GfyG;z^iOXe8@UXmz?vx^^pGEUH`+7(r)}E?{-9%nsa{bb?jEoKo(XukR4YF6!bhAAb7V`)c(;()V@3GlHzarC58P?0sj>x;Fh-! zaNSb}_||=mTrIGXOCxXa+Zi|b^ZS2!`GddwV&h=S5g3dkcj)4Ofx6gLVTLCQ%<$-R zJ3L}+hf1TDWAXUqINfav+PQDR4~K(L(Gi5*r$wT&Q6vTpjK@3u<8j!wR9v+)6_ebu zQDb>Fnj9`b!G8tF%1Ut_v8SsNcmAqGjWf0QP8iH`fwaw^ubu<3XUWN+`mtkGad{nBMkG=Yv;fKCv*!#wKRKGSJEwlz> zC~=^ni@P4_;>ugD^R$iEIo~PbIV(i`VM7$Z(GtbmKACf;p)>iTBWD%2vd${p&*ilz zFUo6wa&@1ew``vv`SD=p`)DxRyxNOtE%IVnh52kwPCk44^(f2xdz4v~ykT0EZ`iH5 zeL-g37j$e!Le#vGFz5Ib_<3Rqlr~$!L7^qse^><3Zx+G6uyyb(Y8{+dz5|Na?0{*a z{jhfbL8l){AoDJ1QnUI_hq=XD9Z zzafEfrD8}RM&4?IV>c+*XQv46Y!^XUU_DGHhP|%_laJLh&W5S~CPQChal{DF4<7;Y?R_A`uMgPWe8q-ee#I)(kFerlN0_59m-#j1 zGVMSQRu<>MOzx^O_wTAq*<+jF(UYx$3vP;b^P{}|VdXW&!k}vky?em5sinH(TQqn3 z63tB`n)ra!CO)X_Chusz$)~AwLs7qOXuNa?&R94EXN}N94=p_`wKGS5M{^wV%nonA zvcrSVmSe<=<+xGT8xM{4#?R@&xFsbRc}^s@6MuHZ;~D>WTz@SU|K3c+tH-kOSw}V| zPcOs-qe99YD8ro^WoQvth5vR}Vb`2GY`3Yy+Pw{UoES{nsfYSaIAyjNYe-L(-YUU$ z)C;+vFU1>$QXKY@a^zmoIYzVVmAO*N9FSmKBWd@iit(GV7%erLaQw(7^zm)LcSN3H z9sZh*9I}WsU!O|xAaPE$5H(Z^@xR<`T$Z1WNp$}DRHxz%%XqA2^fRoC#OKQ* z@ncsIn)VLHeh0ST-uNw8?z|j*?CIyXx5HNp?C@5O87|8;!+vhMcz3Zb4vid)lIX#B zW5Zvr=JS`=f4I)mpIqm=4$^tXMO^oH6#v)#06)HNCRg1slW#RVt4OvutI$ZuY!4S^ zwl~;?3FR%;GRO zwJio_`NhC)?PRbe9=c>gDRJsw9(Z@BUdZGUcuefMUIEL9>u+md^qU%(7bt{h{-kx> z)(Bty8sXxlX1H{v8G=hCP)aPlDTN0_kY5Yf5k@;&K$SADD(GwV#C1B;#?#rRM1Mwu zc)Ftz?r(2|y&*#A87zdB4>fS$V+}mGSOMd&P`q2f_+n8NVk5=`m(nyWZE$nZF-DNh|6cYGV_`5FfaCTlotyfF_E-UJEE-OM}84vVfd`)gNpPLZPmxnj;F|kej-;0}E z^W{x0w(5pEh)dN&@KwVQyqBVfkgA8>ADW}aU2~jVwg8>V7vRZ*D{%Lr75G}!2W10% zFtj)XdliPDvS}1vF`>+;(Fr(nYyuilALMLi8k)!F;P!+Z{GeQf#XX7;ewSh3k1|~4 zRgI0F)#wcMcxF~T{yacga4{n6HJkc|vzqbc3d;TWp

_2}fEe?a9#M+B7P=6r?Jd=|_jc~l03A?Xk!t;WB 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/he-aac-inband.aac b/tests/fixtures/media/he-aac-inband.aac new file mode 100644 index 0000000000000000000000000000000000000000..97cb58911f756756d972ff5351e81e4ef13b3bba GIT binary patch literal 1450 zcmezWF`_}>{~tvIW`_*v^4kn83_!pu!N9{{z7E8s4=DH#bcV|OKZ1*_JG^A+*Z<$2zyE)qWko@|5X&c~kaWSFHS>4vedc{AQMvQ9(6J!p+55Z&9s1%< z>i9geysfgygyVVEq*K1it}e@DdL{{V$*^8C)(N@hvBuX?ELCNVz2V1qYoF`&zIgxb zul$9(|2_wPTK4_kobY8P_X=*5FlZP6J*;^u)$6C|NwCPwiJg}mwbHsgW#7y9*C*&FZ+iRb)8%mcX>%KO7Ek$T8g0tY;QjU6{S@~5RR*SY z3|#U*{vZDb4s{lb3F;FvFNs5=-~lL%kH1MIGNy7Xe2Y?=7+#~H>1<&efe9YrEo&md~6gXRIyb!`>FX zKll1j(+0lwCpyMO4`**wS7hM?e^ziU)wU3 zzyJU0`TPHsEe$^Z|JBQX*RDUjW%d7eXpC0nbUTqbRvi`AjQduWHXc8`Yx{wOFE^y7 zZtH5}d^){s-`mp5v8J0Z`JCEttl-+aJ@pwpr8_!RE@*4)bvo_cG@EN{_!PdAk{>_) zzQT1`?@)Q4yz|E6(;6lT&*)j4@pw*gU#Z_pOE~B_l4K~+mLsNhY$uY*7Fz!M5Ddw14shC@uoT4CePhi{m3wc7MOrJ<)YU7}XI{emTg^sH|*23YX5Qs!8S6YW^FeLpGRq}sTZ%mon z7lc0L8(SN_WA6w_NYEqO^SncxfTwjqLKtSryk$Nf8!J$Jk7Y;>w8EHyUE{cLEc%_H}${`^n0Rlr30-@!m zke+Y}>6x3H^d1J=>e{YlUEM_;*VWaZ?)rOw&-llAopV0V_xbkc`#k44=gy?mxiqDU z<2!>=${oiHPN{E6O8t(XyZ)KuYlBnX(UsC?TxUyC{#aMaZ*u?Z!6_T!dOO$Ixhbvf zO4&=UkAk(FFLInSC>7gPQ?VZQiLO-q5zR38^g$^Xw)Of@xjK=%atXI+0>+poG( z?Gf01j?sft$II1J`yBrNtN#esN5dzwadI_PzV3S0YU&y7e1>E0;MACdc9>)TYU+Fq zM!&RVaH=iT_c{F8(LcpCS!vDS)NG+0S557cooB;B@4msQe|0r=zX3m|nmXpw?Oyng z$=%@kO|ma`EOk5ze}8c5-i){Y?Q77lAY&-}e+Q?|t@zAi@6FXz$FuS@eCMvz0dFsI zeJ6Y!yvOz4Y{#dk*ZqBTB&+$Nz6W=uy6d7K<7?;MK0wPxtyJbbQoU<$4aea-u#74V`LZ$XV|C zZSs*$HF>pJ(8A z^rnhD=_EVtNiIA+kN=|Hl*y|)pOraHpKLUH|K6KY_?X_s9C@%3pBK?rTB(!XIl6RL zSL$YO@iHARXc^P^h93VW&-f=9Ygw(8iaY7C)_rA3>I;8P;OB3&QmvaTK23jEmipO| zCU>R2XIrT~7>!)W@9Rx%cIU=$eSAwDp{LknZ_}BdiW%&B6W#$6%i=$J%Y4Y_o4wUa z`G1iEZ|v1dnfxi5=ohTftd%= zfA^;Dsbm-%we@hdB>OY|Z^7rZQqT0NF{k~PYUE$h-^iEmw92~E%~$CRbH(4`W5sF- zohHh;@Kv!i75{)gr5`_bz7EGPEm@Xww#7K@!?!YCU7P|xqBo`O`DnJBSjX>U+4Ty2 zKG$2$C0RdQ4&`b?cMp5V8lO8>Q{}^I1AF?Psg{1q7oy)u-*d!6u4Kc}+>icW@c$4S zKLO{L`jBcWJ_-Me9N2|E58?xFKZ5=|c`FBsUFDem^}y@;9SJ{#uDiqN*NuPe^K{

sO4Q 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 z ztM~_A5ug6RcM{J(YHQAt5A<)q>aHbw%3Xa@dYEH)fp>}T5C4p|Lk{=zPd^>I(3k(G zuvVb>``+^X>Mr{C;FamS;(OwSz9QxS^4^rL@&AkLi?#-O@#CYTTZrqIT z?e-1*iTv!mA`g3=--gdO(adD?Que-p&r*0@8#9`2e~*v+kGYDtPmk{d6>r+_pf0H| zofs3ynfSMMb&6a&jI9gMU##pL_&0j3KN736(9VG$1D_6m4j%VESK>3%wH^7H4cSnA zd)xjtF`Qa69wKj&^Wp4TOXr8t%CA@p*JrGgj-tQZXk=odJqbVNU~{~BzM5VAG@~)-jjZte>U*vO>84O)-7Zo;{OVL zH>16p4X?oa;2&w{)U6m3XG36o6yM#Aby1ajBZui-#pivpNAuYV*RCQTo}9=>v(ewk z$L39@@#@h|U*zDAqcYr?oU)@QfT~GF7(;R&C)1hzNyCnY`|Fa!O zZYIjpH`^1>pW@Sl$GfpbUul0N{Ac|9h+}PE{HLwO-pU#9|0BM`m3Q7pS>;GHgm44MG_8m6mQT8q| zjrcxg`xAT_yUwS>NId03ORi*p=6k-(DmHyx0gJhV_;yOM9*;d7FH=^&72}-$H@SQg z_f_HZcrUrb#(E<6x8kvcuYL=cTQN^se5zf}8OOV}702bg16`9H+vPgutcrf`w*Lev zI~|{$(BL26qU#)r<~w3I2Ol|=hCairUpv9^8~C;ObXV<`x)T5GsZ`OQE7l+3wHrR{ zjrB&^kaoFtu+G;}XX5!;s($BJ!`){`|5x9Wo->r&$nKlqXUj``i@nsXckualeD1*C z__4Ca`9ADz;R{ACLD7 z(GSx9VEC)Xvg6drH}HCeZSaI|lSAe4TjWmEp4n%?|1kI|zLT0O{~r4H@uE+(LCKEV z?iuhYXlIizPQ?*u(l9`|LRjW;=P7glU@IapJq8;1{c4|(aMTV$9c~@a#!KA zmR$abIg`GX=v!p0voFZ4;Crp}wc@tOwvj&Vns*oY<9^2*T-T09e_dTppX2ZtO2%fi zWMtd$T}+?j@!*4&Hoeu0b~~NavE)SiN&jTOthcPB;(LF7kzXdj%nx3w|aY&~>e#FI{827w@XY zxTlLP`N!zs<@oS!cojxwzJOlefe%yeX876Y);OL;KY0CdG?(+kV8{Fz`O$(Gx6y4d z{2bTCynN3A-nrEIar}91yPQ95$;~>v^!xte0F(%0RV&}$$mDDKZT7*l zA8Q}~Nn3C}ThVsD24im){q9P6d;vaBlJx}`yUTyMyrXTokZ!lyF2{%6$uxb+W7;lw z=OShJub;omd#uy79kBc`*AAwq*k(uIvB|yH#dPUc_Y>DQpdSVQwK>NjX!Om|7PiE^ zTo-ei=OKY#57+<4Jxumj^wvHX-O3tsm;Z{93+dstay}rh!E^UuyJ(x+WS>_n*Su$! z!0%M{VC|b&W;gN;o^^hWIhW)i>!svf%*jRnT1naZXMUo0j~G|8TKO%=4m>{!&u6up z>S%q8nCxtS8N5aZW8c2{?0%zGKFjIA>vzC^g3lbWpIl4oC&BnJ{U&_pTKWHx7{lda z68^1@c6@~Las!nHTyXV-imN7*IHejXoqjIAo4u6bVx-iCh#Ej*oz$L+PG zIl=LoS~-su_jr|$9Y0ztzpWDUUi}%n;dyT@X$+;$STvjQ8%gd}cq!YYQ!?85Ry3|> z@cPE!VH=JH-uJq4bj`NZynAZHw+`<_oq<=jku`-q@+(2p%IWoKTi~;5N#!ABgXeVo z;C*Q7?2olJ{c9cbU*#Hj&@9H6{XMH{UcVJ>1AAMUP zc9lgn?91iS$~_j_dH$ z4)w4z+h2Ugq2GW%ycmO)ZTT+jA4acD_`~z(=mJk-Eb$n*ThPKgUs4tx|0Ag1rkwg$ zVN3loIR5E6eeI>_x5MDsv3y9M7}Iu6!2c7x15eP>zj#$1@2>23@P-$?a&{fLU)Kl! z*jNo7?;U54sINuMGgkG?d|$LHb$GJQ*nvM|ZD4k-x{iMq_?hY--zCe{zv=Fu=#vM2 zQE&N9M7)<4_to`n19+yM<+IUyCqFqtJ!Wsvr@vCy`80U|kC*g`{1bTPpmzD~u=uV| zvesC4{r~V0r&v>z9j#vel`Q@b`smZMG4LMk*Wv73sU;yu*h5lUpe~!->`0N3^ zs$Au0_+R+@O?;*f;L+~nyVlBo=ydR`+Jb-di>WrbeC9LL@x%fD7xJjor6QL4%7lI? z`X2sEUS#VT@^=ZEm&t=SwmCkDZ!VAvZGKQM6L=y%$!LxMi}zH#6U zwQ>zTJa-F(2UaI!jIA~$exNN$%S_u#+Pd5?2=SK<1*a_wPy;u-ZU^0^xI zY#aQk+CX1YRR-Q+OYvWFDyx;>Qcf!9;5{?gaiINheB?s9FaGexJIbBrcyz7o>m&A2 z9~({dWHra4r$gLxl=b&T_~+W?{A`ua6Zd2(`ustBzKZVz^+wxJ7=w1M8OWi8PwJCu zNp&82_wE@y9VB)m=`$Jsh~c4fF6j5NkzI}UU=sQ%=E6RW-w#|DyT0jStbdMgPbPsk z^+h@U>OdyZXSPr1OHvR2foLWEHD2X665Roo#;Lzf3I?V=k9V2 zpKO>jir8&yKK%ckbiI$b&V`DYy z{I(A6eU(bL^5WDIr#^W9`kH%Ox?k@)ym)&6uWxjo(dBmKFGW9XfKR>&{c`w&`0K}e z77WNO+djG%Pvc)hzhpqq<`#SoM*Cu|94Ev*a@dt_W!KqaJlgec@uOeQTJqs>j~MqM z=@4|}h2P3?|Gi@Krt25C%l;_*T-}DRHaylJ6&LY?^3m5+;H^<;r?_?@Kf;Uo1J`?A zM>7N8z&F>*xJR2_c@OPex?X^eUm{Nm`gfsieu&rk@bJ65>Q}D9_WtO<&y)Yl*((qG z>Dwdr{o)+sz~(}HM&iStjb1cu_&Mlj;qxu>H^5e-55DkzIz+wb|PGrvt_zUJ*a(pJi`8z)W{x|J_KBVyud^|pUT0b8DV_oa0n{UDRu%+Hq zj%!=1)GojKUVRoDDw$KCfLqYpyh$&+0Dg zVdpS%7t@o>-$>UF z=ytU-yXhas+8+ep(k|EV#+*m2UuuqIhrG$QwXLs!Rc*_)OBp`<58zFCpP!)ozxF2e zC(!RVz^AnW|HIIeSKNzc-vPOm0xchu_hX-PeV+k6V|-bB%62`!)2qH0oLyZTVZYOk zeucdA9q-XD{gBClcsC^a()vkc?XDcZrCae|VSa2hzegVLI^gG2jzyjpueZy8@l|#i zh)WY5?Re`wWq)lPu@gDs7;Q}UW%M)J>LfWkDktt;_{aLD&H{Sim+V0Pmz1SX%%@eB zDF1DI68uws+koDDT7xa|56jHFQ{-XJ-f})|4L&~?Gxac0rjUCvpVqIvOiqFS8QyDN zf0S(>`N8D8M?3)?T*Dc@qQHKS+D5oZ4c zKakAP@bBQyu8eQ8f5huN@r>O66+XTEbpU_;1N;`)qspkS4Y{410RNHp=rQ>1@ICpP zpX=ws_3iZ$;{0iw-0qtWzfzg+$zQtUXWNeB*LPvJw9EO`Lc3bt#|?tNhR2ubvz2aq zP}$jbkr=(y_DoutP53XYCFxJP%e5`VGw3t)>(DQ!i~GvrGi2TaABldo_E9d!v&Q;u z_*^5uUvaGs*WSc?hXr}%dRjS`nR~2kMsL2p^DzDP*N(r9&tiDw)c)?ZpK|^OzQ~nZ zhz~s5iO=W}&sJhyD!wNg{cC&!FV?5UbC`6F>+V64P5kygesVwg*T?~UW*EQAwW1Hd zv2gFfMqe4)?4`>A?cnSlR8{1LhP4!#S>Jwx4?gzm%c z@?Fk&zK~7h%gfPE4nFGATKhN1(+(wH;F~Gr?We3k@6|5XrlsM>&GcW7{ycs@1I|~CjW#*cSc85L|6H&B z&>>Sk+PN+|#6NzEucC+dsMCSh*%xbBJ1@`!9`i^UKC#v(JBTjh)Hgae=vODVe<6Ht zxIBots204kf&TO1JL4~|6+B|zufwj&EP8s5*%SFl_n>d`L+bBkr)XOKvypb8)PWK_BnP)3JXZ z{`}L5xDSKRpbwj>Gi-F~e@X7C^T|Z_*68E@u#cSN*_yd2d*2ppkrz5tBVPBw?{xl* zXB^5m`KTej(f{W+!0#}Yao!ri26p6U(z6D?WJUQ*Ib=ouRyhFuAoy{{N&Fgf!7c4K{8!ve20vwsx~=Pj$NTd96yxsx$i>Id|A!p${JpBJOHRQ{{wIHOZ!e$X zd3}Q}`F?P5@Ip-4#^}(?cTf95$keLLjGI}gb^-~N~a_Z%eVr?T*Y zeF+M2%k88v6=Bw0wSu)(*=-=SG*W4Gw--Z7R{8jXon(y+; zo2STSbI^o-X<94aXNqSR(NC3gp!3n+h4y;em*s@ErhXdw_7KlHY6Jhcx{V+7gO!Wn z#%tv|l_$vkjQXS=WY@y~!TQJv>fnpYTp^D(u;Ett?>+CG0)N@@MdU`Ex!>_$;d5)| z0MQS^M{d`@fv2`A(H8Z+Q!Ae-M4J@%huJ>*tzLFbrRO1d!z=1Qb}+xJVb>gd_Jre; zjfEerUEfeEznPFGc#zv9E7n%EHlttS_&3@)b*%oj<1@R34g}XWxC3@j2hv zY-?@cS)D#HS;>ZJ=r5Jm_@wJ>`mk8L(LW3QHS{F6XCoSYK~wH#^BvzTuG*Gdne1+I zXY$cP$9IU~3iM=TReix^Hbk3pzx?EitytpXOsW zB>yZ=2h+!K@rp6z?fkYDehM4KAo}mD>9|(&JPCfASp2&&85`5r@HvX#=v)k=voT@# zy?6u7f3i)ClXvN$|LYI>x8NVMVS!`&_$EjPo=kFFx6N|A-QHNU9{78Xt)1$G&FV(X zJ!bSt-*SI*MJ$S`bhyfx;8l6DJGvS2U_F|d@R{NYX|A|gv<6QgS z_nN;$uP!Bf%O&wE7O;!$^@sW54Dmb#w$gR&TyX*XHuwd2T~YJCE_^ZmpXb9X;g{R) z6ziqREXC)Ew(r2<|9A9|`%UqVx}J@MABM*lY$IST^lfo`yUE9X)^8NgH*54S0 zjxO~Hu{zz4(sg3qb= z%!2cMYc%{v>hXW0-;KR&2;Gb4;fD?>zb%)3A|@ZV)fM=C@I*afLyTD~E869}Lw*B% zyD`O%_Pe5Ag+Je<=b$^I*LU0C#YN>a*p_-#In?>%y`EXPzP}i3WV6_Y-o+$*cXK>W z+qbzraL>7t?kjuC_4aW;+$SdrF-f%%I+=0~G1hl?I|07Oa^ORk!+;FiT@AwF9cF3E~mQ{E* z?I+OZNM+d{W0p$f<|O^mJNO@kcB}Gl;<>z*^h`xNh0l+cGw9>lTtj=*H`jg!nq&Bk zof&z3ALIEE{;pMyC6_+w)9hV@{|b5Ca_u-}>5<(_Z#kY9cqmgp9__MPQVAc{mDVZ+H=E%(H&2Yq7$8K>c+T}fZnKTU9~ZK;Ip)6w5#(>HWpWxM=s$kk}u zD}&+YDB}JvUrVnjGl&jy#ely2?`zq%z>_oK9XY(j_l?NU*%~>PoJHn4@NV@ogMY}c z=sWOakUFeiO`fimW7aCXsKR&9@Ah^ll}BpU-pP0RWM{*FtiJvZ{k^sFUv?e3*6El% ztv>CA_igyROh@*X^_34g;#0X1%}~08f6hg>Lp#YYq}P?MeNBB`O&@r6Joz_>`9<)X z`CGf0pNH-W{O+-5W9I_8h<*Kh^mi-w4Hy~4k$AH?`o83Oz8Z$lA@Bqy9}0L6-Qv4@ zh5DUtqT2$z=#jrg)*H$twz2Bz9A)TGd_ca~$2?cu2WHdpfw$C&WEwoi!56zPdY?WQ zpn#^`XoDpf6u*U)Nv2vs$|x zXB6_W$xrdkyyOb@(KqHPa&^9J>u1V)ctWq}FYAZPm$2_vbHwoCBl2_dwH+}|NbVJf zp#M^@cN)>3txgBMf9E7}KGvcn+K#*%pNPZN-DO=!9@Kxn1phR787km$FI;>c-Tm}m3U}TKK8M=EACI@Kg+wQZlB;YN z((w%Y=fr82JV2MigGc>faeJZdU*V2VvB5h}DEl{gIkH`T2eGbv1~2Eozf0$r`R`@? zu3-y2o)g5}Xh(j>oK=h~3wUcXn)}%P2K~QA=6wTrG8?{&x-t=dGag?j6Ma{-`!SYy zw_WPogID0ia`=PV$@lr~Nc!_(@b6kpu9()R!4rJsXyB1k@m^jst8L6qFZ$*1R4n2> zzHBc4j5RiR+c~@L+?@Pc-bO)Yq`Tp4&J=zSwC zrF$Q|(1%A|jdrG7cT4x6Zz>mUQM60RalPdnR>^-8`I}&YPY`pugg*Uji*Z(VDPD4+ z9=e2{w$?hd@N8c`frq_`eShTNC9YNZ7+uWQHS9a;Y67p^NDo-}Gv@xXh@*D8cnC(1 zf(?m%Iev%t48woDYvL968cp>f>xPfVcQyX79`;ri@Y|4DQdLj7+OElmY?*8FunzCc zz*i1N{=^(wbsCxash$t9ZUaLVA_ZtcJ`_3U1C`FT_ef8pjOVs_PoMh3&?ncUo(8*;oGn|Cp-Tf z-5#g^`O5Hb@eMp?!G}1Oe=$zT@K5f+gHCw`ehXXAt(EICvm2bhZ`%p~t7_#qq)Gqu zLORhkHQ!&yBgQTL?9GltI}yJ(^&uzXJs2Bt9{!xR zeJ8ZWQ5}5anQM%RIKG@itH8Tn#p7k;z?a}>Y5&%0GtXl4aOW4h=YP@lSEHTL&Yy`t zpTu_(JI~?gzBhYD-oraR@ILd4PuTBRV-sG3YURJ0lI`R@&@R8N8|#wtGuXBtzE_iV z7hIf@1JRt0|4-RMUh!G@XW@IW0Um8pv6`%F$$bd!{jzc#dn%vw!!Hrv7vN`Wvre`D zg}KRnlp8E=uQ)d@tej&1Gjn<2!;{5jg7e9}<^S!oZ)tnwS8^mi@7u3Y-_RxI2s$^4 zbr*eRDfbI{oKPEhx8hzt7sJ+vuJ4H7@#MKyJcaI$#ueJg%7Jp_IOkWgdAR+L#a5jw zP7tSKmA?ngSl5hC)3x}X3$MVwjrL7+GsQ+-FRpBt@5Ogsmb>qvZyJYew!Z~_H2R5n zO;g64Ld^fi{AF@ft$gktbBkFGpKa(KhaV~K^I;3ww--IpS2Fk!cr12}-sQJ2x7d$l z_hIt3Ef;&8SIlYCCx4}Fexc?+eafzeA8J0$aq@b*{2zS>P1zPbqAy}^*W2tmSUEhS z?}<6mu4U|>=<(z^y-<0&jC>%*=URT;(WI2v)cpz8AgBao-J(H|W53IUAE>>9w(4 zj+e8STw{MltfQ|fHn)>J_TR9Potxu zuuZnOe_PZ$Fn%8ezlh8YwqMidKYFbp#Yf#O+k&-l^HiN9+x`zg|5xD;waahzMvRi0 z>tfP;)3q1KZ9D%ddvB+&b}D~c3|~=pne(5(pLYFSxVqGtpxynL{^r?YJRkQu4LKEK zxWoIn!8_9au5OlNoab??g=lOR7zZL8~ z9iJKEy9E6h*EWg!a zKA;ag-aAOXD{s{ORID4~R8{Az(e}(o{{}yQn=UPU;qh!VzRj5EZz^A=+hSYb!|>@r zZ`|Fv68#GK1g}31-(8Hxi2I3bWls-0zYYH4it;&DvJ;u>=t`gBQh3YzWig+(PP;E= zDZFz6{B89o+OBW)nvXH%3+0<30MA&#B0# zW>GU=hGs8&ZErf5J&}Ja(T_%Z6#P2v(@pGWZ>5)>`pRMu{0jO!%Fc-QGUfiCxYpp^ z=!e&=t81GZa{X{+Zouz+`SYs$kQ3F9;+(JVus0s*dJlhfu=*j|9^2R8r^ttyj_1gq z`Dnk2_cv|FqF?BIs(hHOTqZ|fgdYxHPM*4!&afYYW;FZ&eAwC1XXPJ||7Oh`B786P zLLSE)O+5GN%H@P>v4%7grx+7;CMidUcs3XRG>CpGo?#RdU@iQ$AMwnnXB)aNwLPnZ zvpFX}zAfKCpI*;z&TL z7onwd7oC#bJg53QbT`T~d6SA!^yx959dnD(&&Aj&*Cw_Ck2#{f;2Y2%jCQWL1l~j5 z55(r*)Z;zrDVBYK8;_>yN2iO7|CYmV57_L6qpW9@li|OV8*HhEKECe|Wr}Y2CwR%# z3Y%kYB{BbB$n|)J)p?ws*&N>vNk*%i_o3I9)W3+2*d*J`S$(HBsmsYi-Op1PU&J$p zOspb?Nz}Cy`C0p#9BNE;TD$!3Laafr?7_CEXYn5Wb#g>bCBNaTf6(6XQG81>d7xdc zJ&kAI+57T%G2FaE^#!>w-EqcO`tEX!Hw^!A?SXIO@22d$A)cqBd7VC!@KI+H^GeAM z;yKIlRO>gUtLt(o*@bf$cCMb#|6)0kD;st7X7bzg{Kszfi0!ZQiM-G5Bxh~g zIzq9$7XO#v^XPd$-gJ(>u6)1r>&l9IjPIlU&c25J*50J@1%8_he-6$k*$L?D@CzNw ztC&wqw%b<7iTiuYwWrl_>f1>6h7Viv=wbT&o~|R*pO=*ri{xs#^L6^nEc{Bm?j!dNeh_Pmr>s4N-ytV%#%CfsR^#;|Y?y7nwyeg_@1p11BJx6hCHJZi zr=Wiy{m1al_^5M*u}g<e?IdOW}OjSO9xfEKaeXD1Yv(mH!NFE+m&PI^nN(=)~6eR!v5i^eMc>vzQz@!L?64 zOP$~4+O4(^=oEE#qWoP>?p1WUAI`RzbB=teN6b%i?MeGt@^fx&;6KMvZ}uTue9C#i zXzxEJhtCr2de*Q|4b|?Mx410Am@Z?MI$hBwqoL>|5KE`2% z94yC-VifZ@sj*Ls+zwk}%#r?4{~G?=$p35QpnBM|tzFJx^e@A23%p$`zrmfsW1gZ2 zKW%3}KQ!swglE^)+&A0zu}_`ono1vd=gabC9sGaE!SM4ObqF5!D_tAO{d@L`OPy{_ z`qW1`W?zi6s$=*;{p_$S=8)>HCqEIVXUXd2H*}qM=^XO^5udx%l>)ZOHeK0I@cAeD zq&5AYd?GJdfIjeFu&Yulv3M?3U5x*CT>lk%{Zm)?QkjOBHsHl?*t?9c=+Ih&*J}C) zF4vO>vdggw{+Tu~{%u`VpPMV`x~Vp>R`_4w*LhxozRHH^dt+=F@8g&CGWhd* zw4;6Dhd;yRXhNSZzUW7vg&o@O__lT&SG9A+mRh-LsaXe($L@G6a*kK? zHTzfPk9H{E!@0JoIYyar_>9Cyxma73h)3fEN?gXXcW*e~bRNTFg!3=ey!%P! zK6q?#O}kJZO0TQ<`9=GE;bKz1*0pbB zf7P9&cPM`VUBoTlm8>t~Gf!^(o#O-Pto~>6v2!E(ub{8tcM$tm*=r}FO^Sa*ri1W2 z7`_y}Ho0;!d^dAwN7^5XzrHLxSx%hgJkhT0Mb2S(On2^E5ykPYKPf-{U0pdGAAXPj z>ZHoXGqG65m>uDG9Q%Rut)EG~d@0+}g-=(!AyGP$GCz9Q}lllqx>`AZB*2;Otm{*RmYj&dRiMh`Q;C$1^ zCq>*No#gty$~_BzN58nhx!AQ=scZJ^&cLoQ8*_pA3Cv955ZsS97i2I+uHoq+%;8WZ$N2~Om zuB}^*4|_sxIX`r^JZ#~&9{n%7%V+ZaLu=*#Lz_#L+l0UNue9qWG=tmaKVhp|@L_lO zp^F~*IqLp8=UW{gOb>pmzJafLQnq#5@H&qC`RM5v{aK9bqp!*a;X9q*a&n`egT=M{ zLUxN||1`(v(;pV&!2AmI;@+8!?gHgt#YTKS!ylvM>jJu6$i5Be=~M~7$g2(?7V!Q; zIey5#?An*%OB`QBpXKxkzddZbwp~7FsjF*^g|&gTa8C{Rw{N9#L)ztdywA11jNS*d z2foV%Z@x$GxqLAkU-hVm4tckF_bR=_ARTSLxYv9dJ!Z?n*akCSs>KfR+7sku8SBb*>E_ zz!P;T+k&4wt5@MabYB+yk4DRP4Y`mUWZPdqz^>GotP;LnijTe`aep6p%<U4Uhh;AD{Bx@>zY+S#l}pXUk17=xX7;GkWzW5BwhT>C-nCUN`snt~Mp`tKBng zq|Yq4I+&_g^}uJTb8;xwAH;oT_A~VmUi?8FD%dcEp0nw^gJXDlsPh%}^0hEN?(B~T zJf6{JayRB|V(m+MJRb5Y`+|DXqUW2n^1se8cO7|HeBJqMda9#I)SoMqfyerb>IdTX z3O;w)-|QN^PfYVk_O4f+g5NC%u4YeOE58?A83rH8jz?XC_sy=A>&qj*<32IgbtUlr zMf4duAV2Oa`-VFvza>s-1J4I*<$Y-VWqd-fHSjwe*YTT&4zCKle*=B!70;C7{iN1g z_*^NUx6orSn(J$&pBnG67oKi|A8DVe7xJ#3jTLwf&v$lw9{%h3X%;?w(NIs)|INRr z+HWR@>=a&X;^+C|bhL8fUtb8joD6xKJRk9>mHE;Xi#j}>8|8&hb>2cnN52d z-;^o-U47Wb59(nGPZz+=^Tl`0dgM{R94J!!r{S}M_I3k&BYW4<8Qu`@@tJKX+*l)V58AK37HrEdv3;eM#JqF7gU}~m61H_}^!+q? z^(WqyC}zUKW_W%w`qRnYJfysr=wU1&fM&q^n+L6*=F_i^XQWclg1eJ z30~JP$J*Ug%#&-3J2Q0n7Z;QNAD(-E3%*HRc>vBAN#L*hw&qLlgL}()tuFef7r`s~ z;wC+>Q4i0B@lkf6{hQkOiRhmZgTv?|#>EA${eya(!(C7R%YFVDJObaB&u7%UOYgcm z+FFIj`N~yYn_nyM!P61yZm;w6Y&Y6ILjP0x&?)W*3i9g@i|I1>f5883dAY8xqwcka z=>z0MXIJtTnit{g3cMK4r*geG0N$fdpM!@yjU`nQrR$MTcdAvT>e(XXb@w%Wiyt}a90gbyNH z9yQ>dFR=giTDguffv4gSW0?FNe4kM6MLf>HQ#`xKt*|@BSe2*YbZczqhmE!JeWFZ! zI$v#DhoCI_&N_U>Jf1aHZik<$EdD*K@gJ`IRQ&%AehMD+NurG(&GxzWa;kDNdir-Q zt(9w+5^;zzdvX$6*_$n_mG`Ila`qkxKaouS=~2Fa0-qH8y(jE=w6pMu#-xRIWwQQ%qTr0mV+S-i%HTB~NH2b5!kzLxnLSA@S z>e)4Y^c%^=%FDmj8u%!=btqi_lb%5T70U8o{J$(cgxo>es=aIFw_BP^;5T=d@2|(4 zd-Bf%eNWbczuZ0WuG>%WTjkzmjj}WOL3@+S)p&O(o~h;Q@SjZnce~4PB*(g#>@t0x zxOLuu|Ev1HADSKUjOR0(_;XCH{7+10U$)Rug)P7R(UtMX5z6d?{z~{N^MUfB@&N2~ z*YniwUnF}s#}gg@ z!93O{boehiepQ)W@tKMbIT<`PX34}T=1-dQ`Aoj#K|eyhdja1a@#n+hBG=)`A$%~3 z+#Te9)b-D>HE{3y#r?M!sgO(MyVzUlW8SSCtI6G{(+zbz#v%p#J9F6cFWRRq zlx?i}e-^(s_Df*nw6Zkk!e@WjcCkMOK z%hquAt*UwFA0G1Cu$5#t;rPb$_?#UbwAEgG-X?oJKJ2KfTTL>vW#p=3P5Ko-B9~vA zbk7&Fi(ZY*`22vL;?u|0<{~^k!i$~h_wX0z2A|biY+IoY@?(6Dv?E_)yqC(#lI{*v$e-zUR9+RS1#f8L@E-wc13 zt>j1DPUT-R*0Vr9?wi;iSZ_$rVtL@|ojTNf10VE#o6sARX1BOTzgV9ZZDU8BFBXMd z?F4(`ekjMUDW3u>{!N|Wn|SWg5}Ur&_{39?Tb^P_u6}Bf;*VrBRXVE4n=IQ$RDmmQwzIYu;#z^?{+DNgh@2LDv zwuDX@{7JqP^Gx1l{cPW8dxG3Y*cG`ji=X0}xv%VA_Y@YB(Hm>U7(MQXI~(mk)h_$ytgBZ3<1aZzd!tU*_lJ+r zXP@nSx%1O%W!sY+4}aYAFj*xB~7y$z(Vl>VA4X zxtr;8GhFPFS8L@z1LHoZi$1Z2w!Ei(m%PxUUMrs&6%F`L{L7V1m2G%GNZ*x?zYU+t z*9&Xqnt}Z5WGp3r1iZf7e=8iLuRl-E50!ZZ?zw+oQ7gxR{V%d_7Jje6trO^?V?X_x zFTrwpYUj##{$MTobwxd#;M!z(dU3gap~b%qF^l&OD)KeHRSVkb%N+JIz~>!d(N%(@bN>miRjpx zlAW9=7dOFkZ9h8`^`Y{y{e|YBx4`Ga1N#QM*brmAxX(&@$$bd_yV)6WNVN;w;al7v zeg@qQ^y7=j<;o{;b+CTcfSqOgu3wCCO8k$$*oR%m;?M5-uI=*sit)XK{*-G zhOD|e7<225$nl45+Pn_>)^zgZTXPwHciARIT$G!Km$sqBH!-FypK)wN&%b>i^UrLw zm(u@z_NpJL@`*OMGR5&(j`c$g^{l$mag00J8uv!oOnx?IYbev1i=NNx``|N2n=GbX z8|2L__P&7LSU6k2Z|~EQon6yw-uXtWA5Ip@%Qx_+Yhw!hbLbcMdhQ`EFVQn>dk_C1 z@YnQF%jD0KxK6ak-dGc;nTYXEtO`)!(da~V zam-)FoLgTn`G?TwBXvQW-!l%M|CA?d?2|UXvL*WGH*93(QE$K3YrUSb>Rw}#@_%!@ z1^z*Al6-D}rV=`RN||l82YNjZAs0)8aaYo~8DenHdxh`e3+fDYNAbUe@+F#MZK$=wa@O54+LW0=r6WoIUqt;P0{ z8@uZHD}vP4CUl_eJ+SK3Cg{UiV1+PuE!EmEP^z?fCyl8;xh!R^MOw%i)j0`{|=D zg}?Fx@H$RRhlWn-{R?Qe!wy8x*VWO^wb6C?7WZqFgVCw09d$JBd$P~53tm42-zV4_ zu{#<45ZB4g4^@7fXLC~=4~EPCCOwP8;9`=`BCCcEe?)uVVShY>in;&JHSO|QbQeEV zj&%KfcFMU}gI}(nUI$l)l3(_g&!y^jwS7}aJHCbvN0WD;HYQ^60Q_ljc3eM3ne*BM z?}t1AzmEK3g5Ak~m{U^QU z_e^8F-+HZGjysE&$=OjEG0RU-W-@#DDjg3$MmskRej?w`Mgx!bHNP0I-0?}|ErR18 z_p=4vdbFeY$?z5Qq)*)c77xQ;ho9nnH9h&N?|t|S_=s_KD*hYM@Krudd|x4VB>Xgb zZfTd#?b2Dwz11$)DA!L{ep|a-L*1H-$CP%GK0)U*$ldOINlpKPKb`Y?**-{~qA$m< z^ldJL%ZU!We>3_yXcn_$89rCnlHyF|H?+%Vh5bv(UFF*O^f}8`DF2T9U|;m7abFhC zopNO}Hg>J#?@f5h!5I6;damSUG|ELEn(j<4yqLkx7wEM{{OK0=W%*#&-f5TXW$SqN zuqD>+*3V&Ylf7TUlU~tB$J+nOx%j;3_&R>#zdHVzF-6RKrC&pT9(~wX&?ml68uuFY z8}O80J^UJDiR4%A+t?O!zQt~EJW5@SkqdatwG}@ni_CmE`XBj*&IL8!Ug>pDNfx}r zr*U7_fv4lOdBX?B7jZ8i_hs3IwQ_DeJ6-?JrsfCmhWc<3JaO+aL7NA!ybYhE{eWjX ztEV%`g7=MsuS9<_dSjsEcjn6iAI6rBT!+W{o|=?QLyd`2IH6 z_Gb^gb1Hl@8v?(yUA{9|>4sfP#-G(;ev9=h#b9;^et)g}j$tc&xf|?q`qt#nQ1qwb z!T-e%>3Fm2@X9#!m!RY0%=}1tH$TCvli;6qyp}Fk(d$uVFF`-u_5ge}+&e*)=aq$b z7SxjbSvj%_eu_S0xc#NI@*XXox7FMDA1ilWb_}nss+Hd&=tQhu=BwA}1uxXCs6UmZ z&gE3sM8_%qo0Q4r+Q-fl=kMVIZ)~k4nH)_Q!jE)5RXKRH0oD0(X8}6#j=7`cGI<8? z`G}r#=zSA9G-=>lPOcEG|DEI(?vU16{{xOik$7rR^Z8O^kUodI=-{eucI%#Ik`5FvvR8W zAsTVZ;GNgV-i~Grd?|bJ%;A-h19)o$e5G?d3wZH3o^q`-6uuTs9K-9{l?IyR8GJW7 z#xuDBem+@vCJi$9r0bIbJlcuo20XvQM|_yVE9etrqb@lTbFTgHpbtEoYK#(o>me6j z)|;TOsiR_ET!k)TN1u|fqS--OR4@Q^#y z_;*CN?>VyVGWx)Cdd8et=u)hw*I;}DXIH%cmn>06O!H&gx_Ex$hZt|hJw%M9;u%f~ zZ}3~(8x+4(<^snFy6&2FOODT}mFv^$bV>iwI1wKAwb?b|vxjRBqLojbAZlGe+a)uf2VAGYpzFL#{E&T6fO=aJpP%N&gQ3k@Lvf}`9u4c ztW}R%@Q2`$123wFft$N+EXMCi_{O$(4aDbs=NHM16?D(w`rvdk{{=opeJ{uyHW2IV zcl!PF=qy`hNTag6weE7g-Mb z4wrqq2L@zS#4Cs(B96!=AYK8nfXhHuNJtJ45|WTG*;kT75=cnyNhbSludTJ(YJ0S5 z>-xV{+n=^-|L^Zy&g(U=nfc83`R>m$&pb1yU45Ucemb4D*yK+aor-<=f&BC|#~bkH zqXGKH*rc|CzG6^Y1ACD!VcTrub~?w{vF>_@PdduQGvVmJvy=IN{Y9^G>{D5){{hWT zLO+|ncf&Ws`^=Lnmz{_wUFtXD6Zipg$v!)Qeq+&#b57s#dAY_f+40J&L*>zQf~Re{ zxeZ=d{z2y-R1RI-Cl~CG`;l1R8t>Yq@LaBC{FNQ+n2+KaSRLMpd z78~(@?R`6rfFGm{{G2v{Uk2oE)RBa~u|7I|v# z-3OmJ=tsgAIfnNXY}#A?wB$Bk^#x?|S)pE}Vp4w(HqkXci19#c99#FamGmsy)%RxV zeA+uk8F`RzL_e3V__pNwzzBSHhigO1MPyGQm(5*~!ymwB2j1#`Z6bO$WKY_5h3{-v zYfx%pmA>U%>^liL=#w>MH=WO<-yQ7wYs{IDyPnOX=`q>08{q#dpEk9t@lv!G&6m+% z3jd?|qP6X6e3cI&cR2hsGURW46`QuVtF=U_wzwk~lHa>OSxWzqO~1}(>9hyjT-d-o z^mMI147(QZU(3JWXp7$*#7%rv_pA9fIs8j`#4a^rbT>i#Kl zPn7=D{&6z-E>H29=lojukJX7!qGwxq5&X08|Iz=eZ}~;|ud|(jx9@@^ciR5h^+V~i z8Xt8tzAGI6c~F|)Oa5K{M~n}F$2^m@1@WA(`u?!E6nodj1dJ zz6XE67ITGXX>;UO`4cq1hmG&6-XBOdT~U35BH7MHIwZ>Eo6+}bb6&@1DOu;y>!0!8 zpsco~H5NWuTk=u-{({$9WyQXqkD|X!?x)uQuAxm%p!c)*eoE{j-|Kj-M1M8CAB3Nb z=0#YSYcW<4kNRzJ&lihv@YL}#G~a+<>3Fy?(^UTGaZNilFsf1g?_bjNz5x5u&)MFR zZ?B_22z|6Qi_t$uAMt6z3VVdeB@CN z8VAHML~{mdWdpM~Up1b!)6y0kXH9}(;6czn+GVSHxOe>40(dXFdb z33c>ibe}>dU88-C4AzZ0$wCTz>)O!hQ-ZAN}CyGGjhBAzv8cWW=k!5?MM(`4*~ z--v$${sw)XLi>5~3jAvRIi^v~|43kM4?lbxemQz=&A@zZ%uw`ftLQJ_FF6-&bJq*7 z1L+xj&Vti*Ku#1d!G6^z9`rnk-;M2SecIEPoxd%X+PipGo;>AU;*zh&EB{zu^^Rz) zy-YqT&o;m(!9C+n_$T`Lc!#k^%xaMzp1PGlK;fEvXy`8l!oMrd&u&z0OR~cA&DYRxZJ0A6_XapQ$$WeYIk1U;3?O=udx=pR~;9$ry9F{9BvN3CVy)$(qjug8`*IcymMLgoO2+@*Y(yG__=Vut~K~R z#xL;b$K!cf{P#j_1^h00!V_`G_P6ixd|y10>l@X&uNpj_m&JSc{o-A8@k`K)PxZXa z8jDgs6-UYE;7@Mqd0DcXXH8?!?dBZbqG!x0$6R=<$%$viMd-g4p24k8s6WuIo;~%4 zy?x{!1;0z4-pHR{lTZB9;kRTq%)007G<=TG{_$tA2%hT0M#J}jXWBP;9M4SRzcQ

6OLw&fzZ?eNt8ehTHm{;rr{g=miRaGo{3XT~{qiKyw#8cFq}P4; z4D|A)e1;#Q5B?t7qv`l}>WqAgd%XAtPV!UuICVH-8-J%a!G8;MJw6xBHIZ}tfk%EC`hEM7>=ruQLC1yoXhX6~(I15V zQS^_<^?B%@)c>h7`H}7?hSK3%d_22t-2>m;flnR&vbsAG{dBl|D({D%+EP48k*<}_bNtyGsorzBh~~LFZ06|*b(Ha0|T4Tze29~ zww#yylFN>43wf{M^QA^Lw-)nhJ?scOW3Ht0@kaITK>16tBEM@JK5yV3bzROTe~%|T z?(eGmpBg=vlDkR{1Rr=l-SH3g*>9m&H=->_0-pt+ASdNo`68bD9c_558;m)p+MDR^ z=ks;;^epv7G0zq2{NnquX&Zlb#ajAmUDg?&86+i!sD_tNK$SL8x6!}v)G)}xA_$K4ElUqKFp%$ICiMh>EZZHg@1?bY>e+xLO=X}L6=LM&yoYT*{k>QJTBV@ z?KR~7D;utb|B9bCx0CXC$2ZH(emc60py@L52guR6BHJ!QPho&OKNPg8e>(B)g|0UsnA;eX)=InWsm zABuNVx#?_6#QX*Nzt-?hDr^$Ha`fs{=PC6{U2VRE$4upn^*Xz@tN+N3wrD_o%@(>Q zkJ8=Ro|ntL1&wN*a@^C$TCT{=@+kFRE~R*9E8!cgq1yxgJF#AlULFiYF0a;C?8#Pj zqc?JRdZYTbdvdZ^?$xfoQPN*(3uf`zX4fBK>pt+2%5A`Bp7u5Bv^JpF7yhc4qRZ(R zZE3Zpc>i`aChp&g&tiPw$&bY90QAOqne$e(k3s)ed}`?btGgNpHs9eRY^2XW57z(WZ2U*#KUOHc~dGKA~2OGz%upc%^U!G6y zTDm;0y?tALdKf+sVo`FC8vy0$+d!DeXV}2S~ zp!{aMFNSxam5;Ug^k0hqM%X2`sqnCGHvTJ}uctpe+T7$iew|MLHTd&?ajEO-QjATy z-fUFg1xeauT}BRFJ6|GeazhR{e@|XruFS34pB()Jazl6d(1F)`$#?;O{l@@Xd*62L z3g>(hY*J1=>;gMEvm#zr;GQ zdg!kHCRd|nV+-H(mPVyR@hp0H7h4jsh@|duj73!e~C}% z6z@`&;$5%d1y9-1WJB_E_|f+Nr=5ql=C~%VG3VAv?6uEntm$d)M_3$AuITLF~W89xVtWLtqN8tP$-%m@fQQuF(3!dD9kC?^x)8f85 zo|n~cfStpJg|7b+)^M$53<2-#NB>!H&mhx@!OPeK9_zQd^AFIy8y;hhpilVgDfI45vQIVC z0b`iJhwIP3U?209Uq=5+bnw!=WWl!hKZ33y=*jK`zDJ|_oYRp38lYAKOgVw8`bw{dY*ILcr)2m*(K=d znxW6hif0c6Ui~ZJ_tPWfPNLgYVtpjPuBO8i@8h%J#sp zJa~p*|IkJ?AD`2`i|jJ;F!Xv!nF(|okB0q`xA9(4-TA;|vPL`Jj3>N{pJJ@uc~d6uB&}b)OO=IAB;O5+7{;0MLpKaZPeBdz$U9zR~u4D1+C%3v+74sHbn(rxl zra03xyVh}!ddZfg&+)D9k>WV;W%^QOdsaIhYR+HTXy4)3F=b#`5MQ6F=oK0z5&g5;Np_OJIC>F_-Z43ceX|Ru7+O)mvdd4+kzk+Se=t%7=yzz3pVokItB%!|YtzpO!rXls@2hj)o_ zv?*mb9_#fB6B|_@GZ6jP2y)>y_Qd9n2ung^I_kx)#R@H z@9<~REvL_OJ#*Y^&HxtvkNzsghvn7e9xY!N4aTeb&rEpdcr;(v4lm&E&|!!BvUAZV zc)cZG=dxWs#Q#aB=it%Smj4j_Of>Q**QcguyWR_z$DKm#9&`Sf`f!&0Cii6@vY*06 z`I%0FpXvMz<4O7^6Y&u5xVO*Fu)ma_v8R2FYBwmiIJTQ|74?jUQ+)$ z{v-6w*Wh;&ny)mBF_nEBelwjv0Y8x*!yEoD+wntaPNzeL#|h|OZTP-F+I!$*#gJ}E z`1nnAu?4$LnXO_de$fWRoO+iW>fEUOF7j@eI3G*@CG=mV+?B2!O$YX6$GEmq-CYBd z1LY;?Us3L8+uBAoHy`&n#V6DQv5fn^`ld#;hB!kL^&_9^`jL^V%Bl~Y8X3=<-=&Yg)N6W-+^8oOMWjNk* z#azd1jQir&2FJmFfpJ2Mp7K5JqoZByQrAm!ed+RsF@s~e#eH?mohM%vt2UpkrRM?k zUfj^m!><)v?O&JU`u^x=H>$cAbH&kK7MsxStITA_eduFd65Yxze7_HTtTsvAGVxd4 ztM<~NzBfM4HwNofdFkLh0G;E#`1B$43((7jXuqR9ig}FeBaZnn*)rIN#Mrpt!{p3{ zF)3Mxx;GVz3|`+2pS|I)!LQdxh5qn-SM=Y}e~otRKCHf)Ec}yQY@^{rlpBW+pET(c zW957wx%VXcg|5H-z7PMGPm=eNj-1Rl;4{BbJy$9J2tPplZpP<_%1x7#{8N3?;U)gP z1kXRn+1L3z+Cj07x&KW5mb;7d7`a!%2hi?gZ_5sBRR4F-YCHc8pPz~Mf7r{}&SS1c ze19pXAH_o-SwD>rJ_VN#@k~DbA{}2O;{x&N9`t$nZ|2jUhsW6V%0~6+tin52Ntk$)1h){jS>@9sEzg zcc}Ai+D&zEppBOjm0-?3byyGrGFoM9sE*r!qJ8t)~?31oz-OXNxU-__Z#Ie z=(;)b*tP}!0Woz?l*|&Nt@3_dqk7hv$c^}pd445dY-iI3o7g2I(GNv`z5Bu)WNxB= z;0wA3zf1g{cUR#V8U2w1JJCOb{)@(_!DkCUM|(Da{}b?)-PN;+JY=uu!#&YFLjS*Z zTdT_6ZMHS->f0R2eek-vyb&$D^&a}u(ccYE8-w43BBL&Loy(lx&Oh>gfZt*)8-0AP z-o(3f`LpzXZxHXALC-tUUysiy_AjL)`hkgZ??Lo@P)vZo1tT}=VErlhCEEPy_{?E1 zJm!~T{anobBxsX@Om=l1$A6^r)79U1*rR=k{vp4sUA^m`A5ShF2gah`j6Q7-zF&3} zd_Ddn=)=$HDROnPatGrRbZm(Gmd@Vf%IkbzI6b0HcPW=@7qZ#p?rtw8`6uy_EAgB& zTLAyFHb3yI(5|t+)pLjz{5PJhggs}7FTLWqX^f4Ne}#wKgXu31lYZX}LH%*mIw;7@x&l zM=yV6_p?KsW8N_GB!ABHgeiD`!!>@1?=jTnRoRj++L_J^=mS3lE)T1*nRY1~$LE0$ zcvdZM%MXgj%XFD44uN~#67Q7OkI@#4c0Na$kQ?i>9)e%crmJ%{*F(=E;O}a)Z){ip zeO`N){_>^z&-k19;(1Go;T`u%v2HZRI>oE_M6QG`t89M|=S$@9deRr;ND!F1<&VxT>ETNr@dx`95^*}Ba zGvUu`vkLmGqJNFyqO;{rz~< zH^cvlzZW)=`b%){(WmS1-%8hY-ATV3i*aqdN1vkY-Hz9Md?wNLTk!AWv#ni?N%EKB z%|?=b1^(~w9c}mK=pPsVVf4Qj{@?iQ#2=oga{o#AZS2@pn+Jl|3uDL z-?rElemb37{Llxtwjr5A*MHLvpV#gCK(OVuW8}-HyQ}%}b4cJ@y5Q}CPcmut0|=wFBLW9x!{0-uNI^9K62)ukx&9lXdXKY~{5)2*;o zwhyxJUOqV!?P%A2tPT7F{0MdTVS4RNE*a^c;D@34XLdY+k14}?yK?($$1vN zzO7ozcZmG`DEwJ;&v#ef94W|-XE(|Hw&;_G!}H6kdHpoxf5UZSljKGIKhTCY?s+>e zkpFY|82rc5buakUwycC=hFS1S64&L0T)@4*{KGcumI_2JkKa9^5wm*eVJnOgHPp8iixH;GITl};Z z?R~b{=;dmx+iDFJpH1z`SFy&W_YM5TCu38L+2Ywjtb0tgQSsjYz)Q~MY3~U9Tl8$f zH=}xf|F_Uit6F2k`V)^t^!YwZFZ5AM1HK@Z`7rc5tH_2j-#21AWyuugU?qb|S_^ zaeo-=g*qYk_wZ;B__|KNYJHJ?^xIXvhqnT^9x2A_$)DhRTf-NzjPg(NFZr>CA^Ash zHe2}tUdpGs9IfV+o*o?QL{6pjYp&;~MEP~bDspHbvpBb2l#{^qHQhC!LQ91 zA81r_8NK`#Iat4`Q9Um!cY|MxZUs7celJ|E#Iu3oUVN9r`Js3QUu|HvhdfZPy515q zdbdV8U+DUtuD_)GZ1&PS=0LJJa&#~JCW@Ur=@OS%N03a%e;>Y|iC$ji?1?d2Cb!ak z;fw6Y(qG+;KC@SS%J;Kh-u4~~diht4A)Dmxk6u1y^owUawLa%Bkb40AjJ3n;L@5%oYzlDQ%(oN1`_(YtybXWf;(uq2Fmh;2;i~Z$9^s~^B+xd|5 zBk*BQd^;hVMjk!0ec(rmU&QB^`i?2=4ZHrUulnvl?JfA+%Z&rXWflDI;U8ju_EX-Cuj^ESpk;Zu^kgGq- zFLdo8TL#}--8vEdJLKPl*FCly@a^HNlkD&5tFZDm^k<0xK{tPShirlV|OoK-h}^| zwx?Ww7yeIu)qfhseOaA-10%`MrbfLlf33`e_`_STHL7Q>`8+%hYFF!k^0Q&n8dV#a zyxf=6&ymy5!OlUy0Kd)ndy?xr-?eP8jsvM|= zye?(d(RBwt%iV*F6yr%Scx$sQ@YCEgy{cS+$9ZIKf1f^)qf_W3x8iR`+ByLzaB0>@;>--`d{eUM)dlY zGB>_mi;ubybHM2u_VQ;uv+dGm^{*xuUSFY3hyR!8v&b*ivFu{B zHU6q!hW>tS=P2dt_=TSE0^Yga_2dy~+3?#xcn$^4J)*{xyDmq3t;sdU$*TG5)h7XLs^-_VIK5O2_{SA7ajB z7=L`BUEP=E@btiT_5428V`LxUA8|~svYihfqs`&h&bj0+cO4${^zmJ^Y8-J0J>^LU zUd_kPzjZ^HG(aX+10jh^lC+_85C zS-Yx}u4nMxdGJ5VsX6Gne3N++9`MeRcJ&SH65r~ZfaBOJ7Rjo%c@Z(DM}DtZyo3+D zT+7Go?tL50LjAx97(8-6zYXm&=f*un`0vB`Z$tkediK{3VAB&eczq}S&*Hz5J!dF$ zpRz#@FNe!T^*g=J`R%U5Yw&z5`Zch7&|a?$yu`ocv);9Me$6$x-w8gO(6c>X4-Xwr zq0hB)jQ-_pwrrH!FWVnBh?gtd)mow2W;kERymCd)KbaUcgEkj6OM)Bjt1ICA@2xoS{nzZ6yk>5Ey>uFaqTkzhfyI0B?csxr>$?U+(ZE$lNb$E<92j0g+cG5OR6tDb;atvOg zufy|``v&i8C&)2)$c=sSsP;AJk2gQb*3h$VAMZ>h@OW3DNzdS2+hpH|kGzU|>~h1P z9|qW+|BLz)_|Wd^n`Z;TD`K3mC-Ql#u{FQNSfg6&tzKpD&Ijqi&vi258FGGnTR#Rr zRhcC)zUi5x+(OT2~8CG-@(bmRN&jQPP{ zd7r>zzA+!Ko=-r3r`T$v(~H@++4(K+-w(~!UaCL&MQGQ-{ohjfDDSuzSlzDv7rx#H zf5fclFcS)z~1tfG!KrohSEl_FmJjzDu2-&#uBX^WWuD zbhx{%|Dr#?rmvybKE|5M&Xeuxz9IVk=1Ba1E?4hm`*8Hz$%PkRMYk89+`+d^{CCjl zTs-f@tB&qw^pokc)A2cY^J)EJ^l~_xi3hwq8|{(KKkA%MIt$y?{a1{C^8Zi0|D0H# z;rcSX@sIIR%*Pi`;Cmdod=_Jj^i23i)QcgG52ka8&sK7kt-e1u8omel8}ZnJ=jm`h z>mP^4JzW7yHYsx&xyGOUZ#e%K;}E>#J|=-D#;Ym4b1}~8Qjn3JivCaP`vSIT%lqN6 zP9l>}$+2kPhQBJt@bW$9C)43dzFm&zSM?>^v{CQCPhz`Rqhw|}mSJw)1r^D;$?ZYRrw!RDRcy<}% z{4)5lGu9TQ^XRU8ajbl%SCih!kK}TJ{$=Mi>^@MzD<@s89@7Jkw>?{50?Y@bk?v_juszs%)eEigm=#>^#3QPZOOUtLmSooV~j70eaP8_*Rg00 zQBV1>rv*P8FLP_z{&MBuhB|>~_~k_OY_8rfJE&2ux9IR`@QpEX@&o>;(MOJ?lhI!e zpQH^pCsrK5&lCCNRlKi*U)$~ZS;*kGm-v|v%O2Ys@O$}VKXoVP&+yl0;NO#TA0m6- zM)eK#^2_)?K(9R-gYSgxZI5p!XnXlZ-o&?T^S$`1>?8{)cq+u8x!Q0K$do89cqnM7Y$G~r_#));H+ zJ^&vu=e~$7ukpvkhI-1jUHS7&d%WU)wwUhxH{I5Jpl5r2OrPfz{Imd0m-4S@e{6fo z`+3vovxMG}3x8G@#31HeVos~~J$^Y&nLpumzWuK1u9(%-qW^do{dRJnB;(iG z{i*2XSJ!*^!|TsEH`eKzXMZMntJK|X_9O86cl1lpT&=vki$17l3_d@l&q_46tA8c_ zo0J_#9v`G@m3vs8t`o;~@@A5A^J%?kqJQn*zz^+4^Nj z(Wu@JiFPp7d}s6V`A7cQiO1{OiLK%?7oWxS`7S=_n}xF5+SUk?yOd7f;U7A;=D@eN zt8c92WR%~QD;t$tM8*z&8^s^1@ZUkt628o~lYeqNR>O^13bAY2TJYkFaCXKw;9`87 zq8SLhhdvveug9Ov9`+~ShU+sbAC=-a1rM@2L0`2yc(vg#vvG@Kc>aIlx7G1V_R8Um zj}myui+(!ZuTLJuNBnx|pNK7~Ag&$Wr@AI68g`p3~svFD5aR~>&ry=71Ra`dm0 zvtHXvhxm4EFPiKmu^)$@*u*_gejEPcQIA+Q?Zr68Q_0Qvh-sPy0s~wpa57`^&Xu=*RR8 zuJwIenP1}lLHaF#xBHBh@t)^+3!DC8?b9@Teud9@FvszZT)bzWYu}4~ouBOb1lNYZ z|HX5|k&aKq_b7V4+>Ue$l~Ugr2+eX!@JxqJWKq43q{kHstcY|mShh5B21B(v3n zUF{!&KLEekIUC~t$4hmg91p({{*3XV+({mWz0y~$!HKnv$yEL4EcBP6>vHX%?e#iRBL|o=jFjC@sW@5KTciK8&!@bleJM_!iR5@ z!&VKx8@Q97mqt$GqaMWish)R~mDAO?onJ!#GW-_z%ze=w!v9UqPiUBPfIrM9>l=gr z1Ny)GG)-R+J|AlCaWk4ta%V4g%xhHZzLHn)*@l1Q{UPe=+jwdxl4-^VJK*YYTDM=J z-+2q(!Ux`2*{=HFY&D*8J>EBnwz<%TMIX{dhvZ%OPIhg z582JiZ6%`(|D|;uc-E&oz9Z~YwsibQ@qbqvHVOUb!>{P+9P>%_MSS&&<3|SRqrK1f zg3o083y!~v2mZA!_|&xbuj7-m>st-&0R6Y3IjpZ*Z&7>+|DpKMtt=ryPQQINuz>k9?mzrrn&Y{0RJCg`b0W33sm*_YUcc_`KP! z=Bujvel}+EKiZ;Vp4i?gt{aqbuMll(+2ofc@JGqqh|k~h-^=*B9{0kz94X~&jO}Zq z@SmWZHY1)f^vj)SW1?-$C*iMd6=Gg&5$7xD_ALHW`l@kE|3vs6{_@+1x-xmACGklrB2j-(a0KQcIi(CIX`9%MIK1sB<`MdNNpBf)@ z*xQQOYM*2MWI3ljNb6ctJ5jEH_3_&q?P?8G!JqjC^xFS&4ZMl}_C~eds3+>;17fva zU%|$n9sEXi)VcTuW!y)`duK5Y?1*pFp=60%-HQHW?80Y&4ZVC7|L2$LXYzB{$fjzn z7WB93SH!d24}ZEh`69zV{ueO+J2Jk{Hn~t+5jMlG>8}0nnTYW+DH(_^_1QdGuB3%A`k*7h;n|&KQHo++uuRB-+z_p=(Rwzk>f*-WtR8F2G|R z+ZNFIgJ{&Pc%Q$R*)R@pZHBlU4*z%gu&47Wd^4S%6Y)6}{!4OC=gZf~oGQ1*;&Tc7 zH~8Gh2czjf$#uC|JqsLQ(*xuUL9hMk^y2d=vVM>LIp>#3%I{9j7U!e2 zGk4fuFShhZ_F#t`$uC9!G3TfA?R@8Zw)xrl5_H$tH^~Y8_8QE!YHd{baa(t_wlkh3 z*VVV=Z}nqm+3$*$-sQe>f0OeY#D{J5W9hQWet-I`g+~q@%FfFivnR$Vnd|8~aoZdJ z+u-YL2dXPeor_QTXF9UE#m?*?^z==XOMb?N=N-%8@?hue&Q7-9*{xku55oRK(T}s& z?j-MYSN%mVy|cskO&sby^m+sTX>{S2YEDKx^ZVM?=*ma3vhd51_{fV`cM|V=MqA$x zk1=P?FWE5rQ@Xt)K8VNsP~>Sk2K_Is*I;We`p(hl)2VAC`(`M& zaj=~zPlaFA9{e`$Dlsm}Tt@b3uzR#K+g$&9ZPZFUIcB&)LqW!)DuR zynEnRp?wm50sXi0&q_S*f}h0R``M+>Z_+W|wTiWJoju`^PZyyJd=WlhM*n>|eM6)A zU-&M3sxjx&^qMNCKx?V1x+Hj4}=oxEeV(vVdV(c#evP?(?3T`Vl^vq0A%rOu$n=l+Pmj z3fSxT-vOI!pD6cyclBOgwf68D^l!o<@2}K$bd&cDe8sI7zmneZUUS~SC*z01=syR| zt?<-6+?BAa2l>Vth@AXn#mZ{#H95h#{;GN|629=fEOIc}1z)+Dyp7jOXik=2DcpTp z_1!8l2pi)6H_E`jg4VdWpFeu1ku_00A+!3X)k%21M>n$5KC%Kog>Ub+PIeVsJu9P6 zV`qM`_T_N#lM_)U=ZoaS%1slyr}(Pm!!^#eZ5ceCy~aFEwvIjr5Nq@kH zfr)h@*V1bpU)b;tccXgWvy>a5OA+{O;(3%!J{CL4{e&`dwf>cc`^HAKZm9R$u4Q<` zv*EDoNkk9n@$NcMKREXIqD#T#TRlfip-koyik?2Yd`eH2d=|mg=@?U1eDHGrx#3AtbmGjx4J&C!{f`9UgUj3Vaege7&`GY^RuuB^iYj9iItIoH` z=l?YD@4&}7AB%1jJNUI8_}*gby#Gb#UikEoyMDbEi$6*h&sC+y`0YB%@xWmhhGlgN&iu3;kh;^ z-Z_f?uKw-D;5S_0F^12(v@<6;hS$H2|Mg^Tbw1s^*AdQl(CbU|hZo}M4;QQcx0=uX z9Dad|f4tX{?_$5XQGNF#+LL2A-}80sNXZL)8$9k8jR%r;!#B=h@a86X^ee^> zxqM0Lm22CuDhy{3%uNmJcP-IpcsuBEV3ZN@X{6u5hl;=|78 z)3qDF3-kxCvdg$9g~z*Qg*G$BOI;)3ujv=AQQxEgd%$LVRezdX{4#iL1X?kV@mQY0 zj~(n6^2c2p4j*Uj`1K9C8XI5Dw%f&Z4BR@!&Y5s?V;#PTXVj@&PJX4ItHJLww*c=P ziVvM)JemHxI`Y3_aTQztgZ$a(jX&cVaLfUx@HFm?BlqHd>)v9#9c%yNd0EWa z#)2Rd>M7SxPrXD(rXJo+KKXa=uc}@v-qq zIlmE~%Ur)lot8uCS<2t+{0?-NwtcTad3bh7qxyD6{y@XGs`+zNqnd-tu5ms3uCL;E z33(HolUx6o^B0{zhdi-D3*5kjxb#)`!_?T0O`_10X^ic{~{!bCt&@`qtj@%JEaz z3VNQyK60B)_S3K2iSH`-+4P`iznt!!${+A*UCUW;cEwmNovs`{DZ2Dbdw%GAaZv9V z*T?-udWQ1+R@`nskAC!w`;qi?b}1j@tTL{zliOS2r`d~3@Ec%HjJ=~zOiv~EMRE^i z|5AEsgQHKZpMuXb_^RXm+$(QyShh8jpuefkY@(ldl>C-|2%oFfwMF{K@Ylig z=I8oR@Y#Jy@*{O?3;w@Vzv$ID0w3*ZwmV<)W2~o+J~uxczr)1hLvoOQ@m;PY;tosH zfmnCb50Clc0ex~RH;~1@@y-93k0{7bpFsZ^Wdr}Y=PUCaA0)3v@b#DZCU9*+ z%*S+U?Bl~8bt-|Ei;WZHd42%@y~(#P%AL^1yhSPR(*4oTZ<_~bRPQPz2hwYta{H3I z)V0a=AJ{5#<{8)V-||6SRNJ`?kF zH99`XFLga(@0ND;4onjDW)b`}G=J6iY`~vQ@$5M#KRv?szw&YoefVbJU3|zd_MqDz z;VVKXeqM%7&c(WgSfd}$3VP&r*V}`(#dDAHKhe*|A09G0d=%><%E*_=_~0M$tKk>x zDB`(VZEU+52WE5dS1!K}u5al)M<2fIpD~Che>FexGx|T+uAc4I#I?$a%>B<#;BtF_ zf9m0z$VFp=xEHFQ(H>kQ{W{ws1`CZ1^ugI){KnP+v9IQWj*<7@xBV^K#H$!f7rx8C z(3h0og`2bPp?lXTa+OQ;%egUK{KsQ29h<{pi}6}6*VX@cb~PY>I`W`@wle>O&o=l$ z`Y|%=+K>Jz`2QJ?&G516V({)A)Tx?N+5rEqd7S6bKj7L6^g53HC42WF>nr$w2KKD| zxozzhJ=rF=kU zdaUy}|?5cfw8T~Xq)=tDcVDekrrI!zW_j{_bkk}@#$bs*{y;~G*XgY~q z>Q6k!%U9!lul90AyLy({lzZ_l+IUwk-t+IwMgMAF)h@*Uuf)54xqRw>i`=p1y|<&E z+&B1*`UBM4CGBdyAqza>c6qlod3fss@{#$N&Z;^zs>RE+DN_`*hKE5 z{5%(bb)b4Sr%uKAIL3nMRQg0L%*)qjsXN;o-$Sl8WMH`C9>;9$+JXL4;yX;d#yZaV zW?6gidB(@_|FZh|I$K^Bi&geJmAw)FTgiRX@!Nb>(g&WW@C)S69Qal-+Birb-=XOo zB)`ckJ7Thh+~9LQ{y!0OZEMbV9Xh7;NzM%Y<@tMUYrE(Ii)-;-cXdy|hxx(L-ry7V z9?kFj>+9B`JGNatFQ{#X9}53_^NsX~df$V#$)EAv+~g^-p+|a=jW5f!@IA;a=@fdT z%HS7cr##`qT?h3@KFZcUoA@TbGxk1_OnSuFB61;FZu_a{TGtHXI&yQ0u0{TQ5B=u{ zeI9H5n*3B>1m9xJYb+CT>rcX`qL~L@DPKnqw%wh$K8{@Z9Bo!pOgijp4MF=No0UnX z;^TTo@1Bw5DwCs2UWW61%9eioiu+xE1wO<*0G)gJxl2xEH{%)g@lnr<+xGGxdyP)# zyFSc$54r2`F-n)5#G?C0#=n?&qV zx(&=kuRg>)WwIrn3BlxH?_B5miMKeIoY7CyV+8#dIB%)nUH0loHRg;w=i_9Zx>UE% z-y8fN>W3VM-|y_M`g{|M)!a_n`%U;h$FTqy7(v=kA59Sxl~UF7I-&?=<1}z;fHM zaQ-Xha*Xj~tx7TmAG*Z8qdufF;ri3~@922%EIGA%a9(2}{fEI9p#PjYFvK`XU8yaD z>#vjd@bW#9e2HUmsOHMHJGL$-zSEy-=VPoL^DfnR!1rk4d;Iwx&VznKV{rW7{p9Mq z`^Pp0-!t9PsP4UB_e4cjL1Zf7klg(0?&M z;2Z0lvQ=nX{HvZ7b6^p(kBY}yyp_+jwJmLUoxJ#;`hspvWoqi`03N-w(A*XAa6SC4 z9%nDY$*SJ7VSE32{Fk!#oA{7jyk4Cx6l3$wWc1I=Bf6Di@n>HbeL54`>TdVow_;v}^HU2=%%@rCP~B&gsfY44zEG=LUQJZ#@V9*Fij=$q%RE z7x7YG(|y^DzMR9}5Ax+aWqReqsd7E!E|4c%<@0p<-_LK?wACl{i|`-H#>w*FGkB=u zoz2Qdz4~wU=v8t0MBChe7;nTU=0H3rFT^$GxaxFmMNC(~>-teSnP1Viwo{q#v*)bw z+`irm?`r$cS$M={4cr(v)<~r%x2ylaELOtT@xuYm+1xo2t=MTq zFKbu-+ZWGTs&x%V(|=d`tYX(9G}?k#A5rd3hu;`WXyaOZl7Cy=hr)kE{`bh=$QRn6 zSU1}nIxmK=?yK6Mo;Q_^v2FAp-y&a3((ULc!v9ge%tc@4@0YZpJMibL@}JbZx8c$L zJ#72{k7uj#&O*#>xN-6h3xB|q5%3=?Pd6P=)z?Q`lgf|er`^>UA->U6eS2#&K9A%7 zbM;sIQmw5yXjS#zLH$1XFX)43tiexu^c|}m-wyvZKZFi{hP(H!uORFCM)eLyjI-jt z)}In2H9BV(^V{!?bMddA2|pgsdGJ%{^I!6O6Wdd~ZgpI^ei;0Z=;@!|-mc#J%Evnn zpZ|}1TM7FjIypZuiavDA|Dx;?*f*RnVN33Kvlu3FmOsl8@NIHixybQi7x_0;4maV( zWihu~J|{*W&^Ejx2VQsnC+8RA(@<_Vc{vO{{fa#s)i?0t{j!2ioyXA5!RL@hQq6r{ zDMouZ{sbErp+Bxst+9`>da7(ZN9vVl$(d+g;6KmHVtzS0kX*5>UySBxaXVR@_ij|* z=4tYK{c89j;{2#M?+c$K|JL#KN8z)`oY+_We|>zLsm7K}>!UCyso_E0p}Y3Ir9c?r)a8iVbD_O^q-xf3z;7jmv9OK^Nc(mKuHkNMhpm7}I%JF?&P15xSCfGC9%i-ipr}7@BM~T;0po z!5i?k_Vg*%(&1F{mCsIXSM#RPclB;)SKm$O<&*k>%EDp}Ebhzdr@Ov@K7Vi@$=3RN zcx`Q0-`lG5Z@Q_kl3!iGi1nGuy}*W#qu)xtvbCM;It%?% zu6gIA11~2yhi7M_{WSbeOhfg#9f+x=p(kJJlKg!l6cj5I%2l;p4 z(Kf}p^_T-nZX47m?)|GZ{qEJ5X}Ery@tRd&Q~ide>(8|Gw{)TP{QBPRxD~) zYZ{8b!EYQ)UgzJV!#s5C9(X~ny@B5lS z`RL!p>ngloZ_`&EE^JqGW=Z7Hr_sJbUf}QGiMIAO{Bg9S$-P4PK77a=cnklb>e(=I z;REV=58Yd2Bu!<%&fae4o9QDr(>Zc-65fxYhYyHb%z?LNpr48Ub7VK1%e!2x2Htc` zx3b4Icn4lr4{L0WcDjBw`YZWlyJPjL_Bt7>$UILOc(KKGc+73p#4tJDwX4uc5p^{-at)1<%*(uU^6D6x%}f4%PQu>-g*XwHK5-3a=S(W6vz`f1y_nJAe*s zO!VhP;D2zB`WWm3>>5j!u}K;DS@_+B&;O?%ABxW&ubX|n z*_YAJ$NMV%s`=AG90G3+L;nZe>T(uHu&0(JoLPKA;XIhqtS5dq%yj z?kU7Q`n6aumM_BVQ*vmT^6(b=xPQp?8FjwuUx5yu27mEL|3!WeQNE8%d6;b^>k)GQ zn{URDC+8!dV_$x=U46T-Ya$)iv*~KQv{TKI_#C9~m#0OPhj&`)+so*WX;=SuG_YA2 zcs9p%c~(DN{aA{Q9(js~b|L0?(hJ+w{~z^+oq;!%nG3(l#)n~NuD*xP1L|=7BkCoY zJv;HzZgd{DEhG1n>J8o{JldIfKH8=J)IUd0yaO+_OU2pfSCje0cD0@`gV*87e)zA0 zJ)z9E-_i-1Hg4X+rXl3gG3-j=k!w|3Inp&)vITazea*Ok z2Ogu_)%$iWHk6macX!VSukqagd&AFlcsWu$;MIKED!6NzIup+ZV$D(bv`5SfaV#z& ze^2Mz;8WVwc&sDt(NC49<4w?U?@bZwSvllZd*yNIq2;<1*VbKCj_ zeprD2g?K&32W|8V+tqt!F)v?UfY(@ZSJ)Qg_qg+EbcCmq+k@|$Em7{%{5IJ&bv>OO z`k~if)y1_}J&N&w2}Eln@y9DUDcT^qbh++4pGqIGO!lP9NZYyaHTaB2Pp49hI?vJZ zoJRHDS*)8+T~D=9#aHo$r(4M!W@B6Y-$ngF`kbu{Uv|DGr`F>?&OO+jXwOn+r}Fo? zwgG;r@xYDrJd;0mIPYl>K9@U19lMO4XV8i7JD1aCvvOn9$@)*azrR%A3Q=4C|HLjy?c`5oG^70h*d%p8m z(8H74(9hG(pG?<6`RfOBC4Ce=9L}#XC!BoS95?#f^Nz3AzMj}HujRP&Ud5;9CCA=t zj`4H;q~oRfrts4Q$M?dIXV)E$FYB(>Z#Abme#pE-9Zt8Hr-}R2m;;LYck;k@Jho8#Ry4=XFf*%T(+tEkl^1NVk=N9-O_}_ze z3cL6q?k|&j;0NRTjC`4iUOnoiYbj>MLFh*{s(XWUAxv&0&%zIcP0-fAP4--J$%uKA zyykp5xy#9z<@_x&M%f=Ae-^^~@E5CU4*g}i?%$~X^R_6-p2Dulc#CnmpZeEEzX3k6 zUA5~mPapTo>3aBP@?TSaIDKZ=#Hj--wu(Q#=~r#^>g+}CHoUgebu`|0^EI8yJ?XIn zZEPc*Ut@dQ^*#6n)?n`}}pasC#3H5oU- ze|UK{Pn`aretrhKRyK@X;Rmau>T4-S;vKGmW#}(}4^y|~Tx!0hIf~rH_??Dsjq@F@ z|4|+OI-TY=lH?e4=1L+LbMrZ|7B8QM{y_BdvKJozN8d^5Gm+fg*>fRZ1^ocL&fb^d zzt_LtVvm2c9r5mV+!H20l=ly^n?B{Q$W<4MZ=o}OEbU_sp_&7FM43m)UZAa^^S~DL zKgXlbIP}g&^$fKCHGKFd|0(=R_3p#?e^q^blb##s`4f1fuey(n`Wp7NzBIkeSJo4iJ`<-z4(wyhKUmQo+cf*cwP2OcQ@!3i4IQaF( zbT@SmuECj#&o;+z+8@}b9+HJV-pwvI<1-)qSK#9v%hTjl=NrX&sq1sZWRVzckr$)! zU+4Zc?E4;@H#y#jemGoS4DeI7t1|!MxNP`8Ja`}7HmgrZ!2errtU}+b{CoTZdi9^n=Ef8;xn0?#rQml&wl88 z(Z7a%Y^`F8bey*?D~-Xqy){65zHi}a^!%%|m_asGYJ2uI=n z5$&BeA$tk$ZStba9LNavo=F!uAMU%HoyuEY}TJlXiPs#V`tJXHxr=$M^dU+AwS=7l^b-8~QTwN&t1ML@V zayja9Hjf|9v)`Q^cdJKZY;vN1A$@KZpTppvg#A5U>T*q;OYdjPDXxDk3OTqr0-(HSJS4TSH8Se@dUxbTsD&iD zM-D%)O<00n-EGckSO0?*&k<`)?dL+VUxSZ$_UFnU2Y-Vev)a{sORSfR_nPv<@tG}O zr=VL;AM)#C;Cv8mNHQMZjqF+{2gbqohD)S#Wdjsq^w)^xBMK2Yu$leXF|Mj{X+@vCrYzV)$CPHX*$X9X!Sx z{rsA|{r-O-oXB2yjDb4Q#!WZ3k8gcHdMMWthl2lNol3q5Ut^;hJmQe>Q!>Pw+O_uT zNC%&eHl()B`2#VJMNi|-UU=_2a()BcbK%Yse6H-P=H+UO$j3Lnaa7E+H)kI5W858W zQ@oE~d(+;3ywt)EZ?Mbtcpty#Rr?M2@n`W$yP9K-_wjp&<8M42`o_A;l)g!7F5xTi zrnu3gniC7W>3#g$;M%z^af~_X=JtF2Uqrtv-Nwt=xp4J7i?~m;u0t-=wE@{oWwgs_q`n{u0u&DZ78IqbkM*0AKG+tr%MY#jgJNJhl<$JST8E(iE5 z?(Jeta5>T5I;Z+f{Mnd~BKH}1#QQJCL5t+Z2KI_`b0ojAG45GoezadM)SgF=w&QyI zPkp*tPKrf_N4(D(&l{qz@7f^NVwd&G=h<|99Q~0IANq@R^=z4~xmd)z!BriEm!Bc~ z9oQ#rr>GA*+k@)^KcNge>eu0O_F&r?^MUF0bYw?$FMEM_s)OYg_!UvV>`z92Ieo~D zabV0<4Y*#7&962lS+DM>Q}HaIPT%@&(d)T|bgt^h$p&pKc*nT}xJ?i5!I| z8`OnQ_EpbDV@@W#*V4>CiLLrX~;K|04d= z(5;d??22!MRPWqvo4O+f3pn7|6OH1<$S6f7x(xMS@MGI zUfVVB|Ij9old2EV*Cq4GISW41yyw&Ce+(P@|Fd)-@RC$j{=kbIg$I&jGZKdU$mul{ z8Bw4?K_s>!Ne~(oLHm7p6m{y}f??XzM{bLh<0#XZJE#}}8af5vB$ zvP;zMwwC|rgMSWwnEb7NF@B77SJ^Z)&%htoF2CbgKaoGlUNh+vwmcg4_qN3UZN3Pf z!(ZB4eE%T(GyGrWh2bstz~V}K&h3-^oIfEh$B?_w`8@Q0t4@bGUu^$oXJ3lg*}uyXX#2D6 zTlCRZ;u%JIGkNNoFQ8XWZ!w&yWBX40#aQ!pdF7QSNqR5$)nrRi_uu$ zSsACyDtMoIZ7mo7A6NZ4d?KA%bl5IO$|W&3RL`8x)E094Z_{(F9HMQc?3FLXZ-ZlF zsN%WZ9QwS1cOjEL->3f~^=+aXt9-9ATi`$9#~Jts9XqzyxxN+tWBB{!VvWqeJNRxU ze;XS2MA=w5iVj_L%Fxy#U;LasUoLCQ%1(9;i?LSz3-x*kf3kAAws*L0%YTLc_2uFl z4OMj-fHi~uH)3+IToUD@4)#gl{2Al*essnCFu5^b66>7PZ9T=ezM^h*dS-p_-{L>4 zrX7#C%9&i@NO#+o^GjB$7F+5lVRe}Eq^M(JN&jdl@z z*24dUzcv~*>vz#fa?L+Q;QsrFL}UDYY# zxA8T6;qiUrY>4B7(2J3F)wRHP)= zt{Ls#ca@rAt^IZQhvH)p-RHp7Cufh^NPOz(PgJkSL!Z`ntwHl9`g_oyguiwe_r$U8 zu`&VuVdzhR4~K73Px6bg_7rohgVo_leEywX#87(yKE{C6(;VLl?pTMpUu_qUF7aF@je)R4|#G)_d5IQdWvs{WNkLPmA&SXy9xb>o?`7%rq5_U zs7;Fffj-w~)9oBQPNVNPoQuVDyM44zGLzrxJoqmzEB-g6J^_Dt z%w09khriU5RNkfkdvwO9eOS5pPnT?CPv^hf-$E~ECa1V|Az$oXE}ji_)$!kq_Ck2X z(@Evx|0Wuv>9fA-%^JjHXd)!UhRkAlhk8G+4EmKr{m3D***BYrJVAm z%eAeAux-w7YjwVFJWg47Z8?lx((CE`qT}Fu1>dcqSKwro_@Pclnv%7V{_qlC#Jy*G znhoAhW{kHY#?o`p%*N+ReAu?1-0D%SVtq^&F?1CjcH*y0%=1QkwHK=|Ja{LYZM)&W z68;fcSF;a3b@t8V4t@bqqcUVuMmP4aym zyaA6kls*b~?^)zL^m))91H0W8^B^PfCASUF|D&gPj+dMyuHQsQ&m?@kSeri0@#nOk z3G`F15d?crQ#7{Z>gZ?&l znTUQT`rcMj659p7h<)#Y|Ei~W2GGw(fyX>)^51gEpU`hczpR6&N8@FlH{X}Nx5Afn z@Ms$e+1X*`;@L^RxC%az*OQa*8dUDgthZeHuyzne|k z@=)nZ^pT765#_>PZ8k|iNyZcCma|*d!J};ZUVNT&-b535fFHa1;7>V!+4*e856}x9 z{aN&hC43u`?ZrR}k2==nr2bdg=WX=YmW%hP>+o*5t@=7WU9VnPF8;^7qVAOm=+r0H zPGl#OFCJq~raeuW_sYey$dtV!{?kqHxynz#_vTiyb~Fw8wXQ9OYa4mw-+$y!cy)z3 zsAtOV)g$12+V(Z{TLfc^WOliD|0cfs(SQ%kz%y_8Pe}5#;aFQ-g}2{_ZA8BafA>Z4 zJ;oBeJ{GU=hxkYi;{V^78@U?qmtp)@{RkfO5jX1l;jyl_E9$%g-V)pHhf;WhFT2^O zYc*V3D89!Ck7plYyU5YWOtE_covwoKNk-7;^lzhogii2mtDF%0*R$vT%Dih#6ZCYB zu|jntI}BDgV}`aonf!swu*3cX^nuy(?s8I@r@lMO zo=M|>3qDu0yyrph>EwJHp9N&gO;vb&!=pU`4ZMr3CXopY#COU}G1m2uY=dX6;Mo!J&KvU)`{;uBdn4Y=1^}mY- zUR~wnr(Q)yCI>~3rRxs7 z0}uZmte*4eeoMKy*NX8{-tFbpnrD0?LEgM@qH71xG|pkCV`*WQ~ZBx zbu9dkmlpS&#rSZkK7U3_Zs|T^sboz{jpQ;}6d4caC z*67$j3_jTPwXWfl|IS?LQhc6=t6%pfG-5s0VrQ%I{R;eKI&Ezg&xJF-Ev-X;pKB+D zE{-?hW89xBo8N-=MD^aOe(VtMm{gQaQ|F&R5ASBT>?`Dok;2AK@<5f zIRR}S{vX2k7Sr&^pAGFl<~d?45${s<(;@P0^*H|F=PrJaIr#+7+f^N9Brh>JNO(0n@Y~>=>G}MRxziT9La9);5mZ+gPpHLUy}b1 z$NLp}eiQxOVsJ6~4~-uWQ;+A!ei;1-^mE~!f#rvwz00w76z@Egra1Q8wfb>;xh%Py z{a4U~&tn~9`yevunhq{|h9Z}ZA@@rD>9UWxjDzv{hhFcgpf?^&*eExCi~pL)6XhOr z9)01*(0>NKK0WU7;~pcv$5^Zj7_CidlZ_+LUIaJySKSv)eB<~w$9$R{!zO!{i#%KO zxxb>@+3FWE4yW^8=#5w7+jZ$>uq|{rrQG=r;eWsEPRqX)l{^6)))o}rv})*z>Pce$8r%pZckZw&pQ@_*)o=gP%<#igg<*Qm!= z(V4dyc*TAn<({X{L@}44`!BY72Y)fv7((uk#MVgXY*{=kr$|Gmm@H zjoQ!__;%Y0ywxk_z2mt=Mwc9~fyH>TgMnW0{2}U8BfE=lb25r&0G=~+v1c2ePGO&Q zfgc|qe~Hkj7o zAMYKsm!Kc5zUQ^9Mdn*_OP_%)BzF}0+thPhNB`s!GUwQaiSruXqw$IQ9!u_YWnN~V z5$dB2^@oji;fu-SzCcGj5^XW=OJe;;lV4-4%fKY~y?C*Id|x!4mt{|oJ4c;v==du+ zjSbZ;i=6u*zUSi4ro}hfwbS?xbC-B(9|IQ;iRXnCagf}_K4LK4lW*BN>B0ADxSTf- zaqu(s5O49WQrzdoccGi~j_)h9@k_tw`t9&*o%3Is86Sw}7#ozXBlmK7gS{f>SNW!W zg5wL}7vY0mhv~n^;63n@@i|BP#Xs5AGav1%eFvV0!Oe};LLbkF`sefSlkB|*KA%u8 z`e)Ui;(K;=c_P;M#hSkur^GYb<_z>>;ahqw%)fMHx%hTu zoh=F<{Khk;uQA8X3j)z}G zpY1*F1|zmQu}(j7qk&5C zf1aUlGTXDphhSUjzdPQ18Si;Yyv28_lI`>v5Oe%ndLBODz5B6|59#yQ>LVU%{N%xP z@lN^xJk7=Y5Vp`}lYal-Fq59}SRa}`K_~uB?lfPss9da-h;MPk`;OVW@ag*R6?kv6 zeFc7*+$wHjey6gQ+&+AwZI!gYsqh$UZIVlE*}mGKI&{s3pL|*I4tT$u7Gw6TpHB1P zR~fUZQ@sU03eLCLVtCaYFdds@$2Ul#UF9FaU%0I33rZitzed&&YuzFiU%RZh*Bh7* z_k1_G(|Di-|3G`&Qck+nIV<%P?~~UeAIx=~Pui2v-_}#CMX&I2yyMVb4SScJUxxE_ z|0cNi?n`2!&erjcb7@y)#Y*x+dd{WiaQv5Od&ly#JQwdW*2Q2kCnV>!FUC{8On#64 zEyoYy{|hBTm2j!Cn<9X z`D5&Z&k=C4st0O!C1unj+CaR=TRMX7zjgg|eCSpReFm%JztCennHRb~2mi>^XQP{K z8wc0s6a81I4}K9FUTeF-wNJCba{T45c$cL3F2Z-%iC#_qY=k|phcD2^1};gmFOVBH zXaDSG>>Ie z-}0;tKCIJ+m$b2X{v2~0G5+d)rCh9`tuKH-i%+z({aeL<0@Uzt&W67P*PgOL;`L27 zpiedp-B4|Pv^H|FxPON}8`O6ac_WMywU5S4+S~i&>XTD#GS&P8 z?pXFGzx|BdFtx1S?0|3bCb{ZS9p>5$brMVIaD3io2llAGW}8EwDd=BNpYi0fYx`~b z&o9dZ?ED0MCiHafkF?E!MP!Q8q>5fYQQ_yx3^s2Lg|5+t2hsl^{1>y^M%UNb`Jo+D}XnuoSu*eb?>0~_&A(Z4Sa*3zGU zy44|KC*C`Vu|p<~oBU8xhsGB8xngfH{+rlg3|e^J2mb`#8(iBezF%Zdd>UKnBfgUF z%gsBqJNAob&=s+kY=)mphW@pB6`N@z5rfJ9!G8_^E_@JujS;`d`S%b zCq7TvKOweq`mDq6X@346_=s}xZS1(;h+G@vx9pGNY8<+2@$Yp#Vr(RR{*NCgi>Geo zSJHnQAH7EIF3Qe27T+b0z9OBA&tA&#NAXN{mUFS)wFuolc<->k zb9M3kw73__=#lP=Ch*fPE9R=|+F7jC&h~@zcebzgF~RkD>_oqOe`WYDz9Z75t=IW2 z?z@r$)NzyR)5R$}rQ`XZ-syqm;@cb9_xSU5G_T{&Kh-X@+H*T$kI~|1SgY`J@g77I z|HIjmAF5B`BQ9couzx1IpNZegx%Gc9O?LU`!`&> zmYkbuXS7s{_5g5o@XcO zQxU)I6G9&`CAUS+OU@&oKk`B7_Ty`@6>HSvoxA!{_$O?qizPZId>m{0y7?)mbNZ=P zksD&Ib^ap1&BN;?_@k{(%;^(Lud;3U?__0$v^wLx3)EGcDAvNALZ{bTojLB^wAo4Y z{6Lw(+Uy6d!l%h}F)F4jZ{vL``kAfH8d3K!@hqq7b+$T9-J4>D4U&pti#Y*pHpb=g%p~pw z67|n^7%SkD@>$~?dTu879r!Qt*=)ZT+H>LS*zGlR#v{>JCB{!B{;aIl)`sD^4|{8) zaj%=hYah0ZJCr?Cy;r)fkBvFr?zyd`{YCXDp;wP=SSP18C!@caZO@=D-^P2&k&BbJ zTE+ilR4=0MH0S06W6ah)61{jyuWEJHqTX!&@FH@aZWaIMS-J_%SH*o9TU2jHFE;bf z@eh4tZEPd(3-Mi}E?=bA)ASi6H(o+NddGVI>f_4Yh5pNOAUxJ)#W(z`&$@mk`R_Tu z)b;87fYi#`nXRpz%J&UOZ%bw8M|I)A8UoJ!>zr7&(FwD)Vl}1M!$L$ z-@b;AHjo@dufQLtd!6myME_ZB`2^R?k7EKWb#*SD*7RGtfGNu_p8gLXtaa)u3Gk_;}rG++ued@ z4*WmZ=0(Tw`e?R$0JZ@C!{|QTG2Y30czs=rYBw1j>tpQUDW8_m#5`O6DYWBkSBa|y z+S*vLxD5U!GRLzIylWX5@4!}&_X1jRlf$!UL+TXqSZCAvbY-pSjy>} z!K3Xq=Zit@vOQ4@FSLip_@li9F5Yu>uFxyJ25XzR=If!E+Qywgp}e*`1H z4R3EN7jrSq&2;Ql4{}P^mWy>DRkQh;4xOt*tZh8@NHeWH1w4B>xG^vGR%2ER4-Vdt6JuQu0kUcFZt{;dZ6 zKgkoXIOp5s8v2Td61;Rcc^RH7>2fMe-69^ci|ik#bI=_?|Anr@t9P*1osO5{Bj*eLD)+?0e!GGX+?0fd`t_IzAQLkCf z(@Q&J+N;WV(&Y#G9DK7~dy?81W#LWwrs`Z#E_+VS8Ls@5=+!&sLTVFSUnc*&h#o$$ z4qtgB=4P^glgpn(pO%X;WW4{JMw|P%^3{+}PjL`)Xald?pIt7#)0ABS=l^&gG8>Km zC)NK-^}Sq7!Q(xrHeXf02$wrjcrO0pnOpY|_&4!Cf!uQ0xF7wG=>1LmQS|7-7v9DH z`L51u^d481lkw>(cm9X;NcWs2@`s)Oqy(;IcPqtPcLvB2CFP-c9 zKIm5AGfO)NJ@u{WIriRdsjfvgHtzGB??s1h`-AnF8}NA?ALSZ{!`V00$2K;@FT-c4 zdJJ}a7TkC-?g8T6xX5WGHj8mldq2lfAMp9rO7T?MeJVdcr5G`?P8{5cFEoMmgo`lU!{}DY$~^o9_BRK2g1X5C(&^QxxM(9 zL&*MKPHVtV#DfhR%kg3NbYV{si=|JHze(A}=>H%3hxuwA`aw1}YdTKdV{ zozSUMKYJC=^nB;Mo2`>l#|G7jjt{Z@r}j#Z6RSFM)(hm>DVw^ zBF5?0K8Ec#!`ZGRCS#p?+y}Rhrl-0l&%m#Aeo(8JC(Vzdj~J+a!8QIVo!KhB%OCeU z%_ZnyjXTJ_zEzA(8%NM(4(uYd%EZ{D$ro||7IT^T33xqb|6Hr{{Od4swejkHXvoe- zwu&)-^-y~9b@C5v`7J!(q|Z*ain>3Ac9lFh49$cNUivufGp@VmD7^xop>48D>0rFi zS0?5fUPH4$Tix#bAY1TSXdL(^xl22G=KP$*+{6*qB){W$b*u1G;{d!H@b9lKzR^?3 zJ9a4Uxz2~*sO_m=ePgS*r>O3)>@nK+TkNZC_it$x>$ZwT<7=N%jo}$jn|+FzMIRwu}bdO(ElEv4fOv|ef#K1-|9o?eU;ZI~@>TM8?$_yA!D}GmkFPT_v$puAOKB$=f&Z`g z+ODqdGvawd3ay6>_ zI=Ysw;Gk?3>i;U`{M+(RaRYPmib1a(q63$De-5M=*A6yu>~W(P}f<+3+Rk`6t$+H0I$?c2zx#@00R#>2vBe z8=q*qx8OgIoiB!8g`Uia>C%1h@R_)4oIy|Rr^MDVw#;Xc8S#4wg8_|`a`Z*3o}vcdHX#)lZ`7qvz+zUF7^pbO*Y2HX6A#p25X)$ux2ioucm; z*be^``~&o_x;B8PZD0rfSF?ZQIeKT>W-9*bJK;U{cyudM#5OzS@c6&`{2@9_#YfB} zd$32~XW8GtE_3jz!)uoo_l)tq!x-zNk^7%?{GHXEXV1nw$=>QN$H%+;$-TXuTy%uv z9rRdD?*3%W?(K}fyBv?8YsB#~&m4cDZOUKqTrZya=i)f#@6(U9iasOT1)n=tb>1;g zJo6pg>ipMIjAf7RE!LbQ51B8y8vPo4JZDVa5^H=J_d3;kw7=ExAK|GT*Z4k>FJhfx z_i}ijvGM_}PE6cH_u<+{#E<8mrL*PRh?SRmi|^4E_f?*gHf|;h|Lk4(`z_-S^q#9` zk1M;CO&9eR-@uQ1(PWI=pgm<`H{YVJ^X1V=@Nbf<-9&qccLQtsllFV?d*IqfGspkG zw564;&ihW6d!{kFWlogu_mW@Nwvy@?^n>Kk#Zi~CdnB|QZ0eBg&JNyxTX%9Ov4Jj-zZF>OJ)x z^pqRgH?lFB_LYIJEY|UKk0JMVa{6HB+PufskG?dtTs()1d#;$Liudm8%2vNBw!Z9K z+bSJozr(y#w43|kd^Ny_X#(HX*d9&tsj_!y#KK2te%5QeYVH(G;{vfel)ejXtKl)1 z#ivr2)p)c_kEpyO-!Ap;zk?nB+MfTD!|65z-ZmcBc4GZbI$u5hQ_RWn@qTbR z5vGo{Mevq3(g)YiCuhO?oG*b7MQ^N;?T$x6C*^Bw8SfpWdzOoDhZOfYWS3UJzt10s zmEEf;|2*3aW&`n+EZ441fgcIirV9jSc?>6o^CP|eyqZ}@rpT-?x}RcBjz}3 zn;hd6-{NV$qh9O~^Pw4CN;~K?iQHG|#%}$+=;gmG@G1QG964gJYO5_{A9y~Uo$q$c zFa68mi^vUp7Wy9L7UD0?>$A$8^^J4+@fh(ii#|(QNj^#4>6t8K=l|3fHt4@dnYZZ> z^;(SHeR#jP%IC2u-NL5vKV$J8T2>c_v*p5<_=VkKOd0Q;HAkYKPR7IK;`=J`9(}y~ z6>At0c_6(Rz7~D7*W1MIqi84E&ZqlodYi{j^(&>@=_1d>Gr6u6@D2F<9-fjJx#|x+ z#ds^*jL*@|Pg7Sq#oF|k!^yX@!)4?hg2x#6NaKgO@Ez=S7hV2WIl3lC!e_w0ga31_ z;(GrO_`aQWJjwU?>lOG!*P{-zmi z+OhDTsMmaWTi<@Ka_Fj~*g>CIiG1{m+$eV>`>^%P@W^>BbSL%{Ya$yb^8YjNU*RLp z61g!gtKSul**g18#5sCCE1e7byn5dW|0nS&X47_0(mvVsr`YcX_`ixzv0vKDG5f_j zlz8To|CU|gjbp{ifqeWr{8TvGckh6oS1$f*KI5C@NVvYcD)xhCtWRrC#cS_!Vc%l@ z{$BWBqY*>Nzx5RBw4+bYo`UmrF$US$Q>+n*{F@G;&urt3m1yRw%dYm~vN9UaIvt`c zUp985i~eI^9Q;Xg*(AFG{T}x3lQp3%U8?&Q$Gf86Q+pmOMy8?PpKaxi;{R0k)_yW& z#76X4VJjbhDCP*02i2&QJvJkuxIW91!KnXRG?oJ$5sj#7rwT;FFWXyIRu``36#wsJ$8yB+oL^SeGZH~U;J=b=?`7PFHBq!-J=FK&=+!^+TNmH7`Jo1{UedCD7%m@{KE#`kQg}RPO>cq!w{hGuy07GG{FBS*c{W_W zZLUy{wd_NmHoSBoJtxYG+FpAD{-J*l*+14!BHy>@xCOoXM$U{icF}hF@d)2F*(bT& zrX5Cq5#xjY?XGRaC-BTVRQ_+`UE!k|`xkllkH*Srt5`464EiqxK_$LYe z{98qrY^UR+^zB1SpMLR>T#0_KR+7KKpIh-JqrL6K&~Oc2np-Zudy`)6d@LPj$)WJ-6mpeqzz5z} zhO8Kuwcjll|3i^nL-tHMEoBFICPt%QP2v5Elv(LG_zY2p;ppI%6=>O{Nzd?g=|wW( zwYAEuS4Mqf%~<;hysvE)-&BsV?LgS)Qd<>Ic+9bN+4|WmJszK1)e)YEi^#vV&E)RJ zMr_uA7jfs>z(#uNTXJnBhxfzcTFS=hIDO6u>IjeVW#H9y=)1+nL3m%M4qNb6rXo(_ zJ8^MeR)R;aPRNaMV%$f?eOU@md&v-EjaB%ZF9v!$c-SmauVNhYHq1Rs6@B!HHM}Yx zl{@2)$2)j2PGYyJHc{Q39w+^U{%!PO=SB1{;n|X#@M(@xhYy|4N3T7^|GWg=euh2^ z=rbEG9=(uN;Mtw(!S7u&@Cn=hYX@&!f_^f6rj(0y<5{_EUQ`jMyXY4fspSR1NzoFam7u*;rzL}A{p}uqN|51O9PyEkf^0sBts(c^2Tt`0o(sB0x$sdo}i-EW=YXqOm)kpu4+)pn5rw=-Zx9=$zzKidv zCE}%xUp4S=xbFTm|0Mm<4`lR**Iz~V_r`(vMU3Smox{7dspLKPndi};K9#X$_kt~R zlyL8}#XNXCN2tGL4{ywepQ&!gmW#C-_4mnx*TqdbLVO>@&a3FzpbtFaGGmJpJ66O+ zlb=iQ?hWXFiQe3PUF_v^@r6gbigk<0Pvv_4s*A7u>Q?d0G}GRaO>+Hs=S%4WZ|orF z0yOi<;Qx{wRE6j8#*JjZ)su7$V;A{2g;(T-lr59zj4x{RTG3ELFl)T3vVndch|ZR~s{d>VUAhhJwr^*%p`-tfkIuz9x4+VmCZqn_}T%*tZ;p=jSQ2Hc5%(7%Y! z^3WgNBR{~~DgIU0d*NR64w$ zCt|xHPQq^ued)mFzvwoFoF*E0>4H`<|Jk@v?9E{lHZQ@`&*Dpm$W=}D&Sv7%gZ3$W z#Zpp*i?tG78N4|HFTRg4W^)bf1J~g7ad?HSuhBs))#ahM7pzR~$g94DhRrIkD+}+R zW1GsCVm1>a{iDc&m$aP}&;C*N?9?>`&r4gy{9CM_h&B32#KN;SczvDnuhRJqa?v*) zCifD@n_I;*^zL`GLH^0_EEnH7Eyh_-;`2TlZ6W)lYZudVhx5DHeG*xG*uDe}ybVvT zcRaP#SxbGYYZt+{lY0&wX0|-1!RJUc@ak6h(Riv~Ji~17?f63YCVcjgBNw_lsSA$p2RRy1%!$=gIcN|Nrz! zTcRA@-_&l-!rMK6dKh1Y{ceJPq&=L8k9&tIUKP1KS>S%%eOa7as9xli3O^*f8@K)e ze!An*%Hn;$t{sl)(*78|PIJ5;9UfB;csdg8arUQ@HM%E>*zDR#pLOa%mvnIG4j<(h zp2obz=TY3 zDHr!RC3yCT^KargNIk{-z(RZVN;CA@N4q5UUWYG5#}4(;+Rm%$a=fx1l#6FfDZ7>4 zQ6_X#x9ZGtG1nD#j`_&iyY|P>uURhUKI8tRe>OQ2(H?DEQ7-P2W1cPUXJbwwIm-35 z^vAn@z1UbzhBA#KoNt8Fu_;cHHSh(t!*{S4&kMT5 zZ|M;D4tm04jMW`JUWHz6jB#+vHjSW*eap3j$lY2l#tlvFB2jLD{Omw=-c0{KwxN5D zzMt&yecV?zw7tp#GUyxiPWMN%id=PS&!Ln0)s=1RtK1U&$x3HBS1#-w-#3YM3~g;P zW0%r8>FeCZobQ2qsIqq6!d@6RxT(P>k|e>;lbyTzv91e+lPpg;pif6 zMvzU<#CR+JwRZmva$d4MOFwnaeuvLgZS6rkzDDkSaPg8DV`dlkbk@jynGLSNXA^q! zTJirwk?%`Yd`=~o4dZ)%@rj(E7vQ(Dr}z){>N#SD58|GrTO4Po)`lsd>+p(6ZNW0bbhls zucwdP8qZE+Ep0v>p6T;8!|_h*_|F$l^VI)6eco31vve85K2yc<;&L&c*4VCGH~Lp> z+C+Uhdh==Z9q_+Wzv0Tf3h#CP3qA{cPcblr+;2v%aQ+AS1fL`HB`=DHd$qNV>bWVzNq>UejDiZdHQ@wjKb5awATgp+GotoYam5VOm8B0hH|^(^=bGWp3&kT^UJjf zutROv!mrln)4TCS_?z@O34S^I%<3tgp(I~%ZKUl2_(k;jCvrC6^L5*E=&yx`O%Hdx zw(L8%bi5ySyYo}&N$&x)>C44-!9jl#{0Vl*C}B&W)#CI|l!+s=v5tj&#z^3uOe3S2zC-@7$h_#rj zjQ8amVluf4{X+X?>ZvXsk`z8Ku~jA(;~u42jiSE|KAHb*x`BOmX%(>^W5iP6{=dF-s`?(Gtvau7Z58)rG3TFZpVhNE ze4E=_owXU}2=g6c=4yQC5&eGLV!Rz=m*TcKC zdAfFGtfdgbfLT><|R`X9;l`uvzb ztg=H*T*Uu3XCITtrl?mRJFr`f!{UGIOX`%&>FAv=q~|=i=l9u7Fm@lHcWtIPnF+T( zr23e;%c0%kBF1j94lCA^JoNQ&*}~K{GIJ0&Q`+Nv0pBTdG^vX==YJ6 za<*mn7*AF8!3}Ycb@dc$4`U1yeM|b!^2O)iNAwiWkGtTN`S2y$-3{=PJ_V2N;4>Rv zV~NJa_Jift;IYlM8P0#J-43?z?kW1UN*}$aIL}rT>ll-Z~Vu>0j7)FZ)#A zp|0V-%6R8rLH956;|`ZmK2KH!Au@0nv2H8W{?RvcaR}4OFjK+7hN$}^{?F;a`==x1^ zMznmd0sd4uX?z1dEZUq{uF-L&%Q7itp%eM}Gw!n*5F~)@39=j2K|ko8e2*e*$i7nzuT5 zav!-X9A6F3#m5Y>icjNlcrTe@^EKMj7&({RT-(TVd|rZIVgCr<(K8(e-^gzK9qS5< zHQDMF>rjjNB=MA*GfMU#OU{VDdooh~{H>?Mb%dcs`$e zpDZ+ur~`l3`K2)x{~w`WqwbdkuCL+8c&3p4E&s|3+0F3ZtoF=P?h||Y&%}8j>;czK zlB2~*wC8xAJ&U?O>)PkEt#9&;+>-x_TsF*y(P>z3@%~F?Wvf`f9{oV9J&*4p#q+U# zIWAM5Vr=pbJ|FcK?@=&#=Ra7&;)+^@w zpf{IX30zx^dzpca%I!z){cDiiW^!L~ z{w7`ct9k(aZq!flL3OO0HKFA_5akY3-gAUvtbsPxip88m)`kB7zpk`3MLrt|?l(U#`J4<_#t@)p4-yS~~s3(o&-cFAX#J9FM*B$*071l_ZAp280^@ln@| zPSr!vkD`ONRGN!-AGzu?f8vy@eV|ljT?3H4lfsTEy*iv9s2P}`l@nA;5!e^ zxA^Nr`okMT$v)CKozur%Uj`38-yk2Ju~T}d_PLUc$&G$DKZ-u<9be7I>QvbTyN8^k zoo`X@O!C+8;X3$D=#D9O;*MTbb!{tCy_`PBs?SdQUEugNw7HmfYQr;lwEL6|;(1_p zoNHpIvI8zxwaKioQF?%D$FoDUgU34UB-VDuTvb}a7hSfWc0>1=n~k+E$qDQtE@E7h zD;IMIv4*Gtuk(L$hkld|(&rt^|J6a|;`?;*y{jslRUg1de^L5`deR|#Mt+8;H#*k8 zwBZp4>0CO5zTGzOOEpepH*psC@p0eTJ_K%UW_l92FRR~fcnAIO=vdJYo?JFwKz|e) z1-=q)4AOwd*rSSXPHyrNJnbp|zrHaT-)qss<9l1hI>y1};yG`uJ&G8K^^X}F7Gs6& zXr6$F{fv{cE%=;@emI_VO4%*DQ-1u1p5lM)V~u0!RWdfiXQJJMj;EF7-_E;@VekCs z{3B-5)A2b@IksqSP$t?f-P`b3v)-JFb{$<$fn9+14AWeYTz%V*Kh3;%Tz9Jur!DhtT>-c;N zA9xc@BG#Hq;Nz7Yfd}5Xwv~$M#v-~-)n3P-I|uzlI&T$&^Tqi*Jjam%ugz9wI~`}Y zI^WdAD?L}4CCc();WavRv2pta_(r_f)Bj@S*15)(?Wf^rOPlC_ z2~2$>H|CGvzgpQX^oCbLU$#l_Bl{zmI;5B4H=Aw~>G>J>LNe*phIg}Hzq+<>z-OlR zL%-@}%DicN4PM5JkCN@S9@uc(FnrFn@m1grc=m+-GiXm~72j-X;**xisgUypdLB*g z3;Yps;W3w$-URDvCDkv%=$T!Pz6;Nb@!7rQJDup%E4c#xQ)7`qu;@3Gt+PWT@b|>| ze(=ARcMenk4fv*bofvIR+sn1f|3JfbIXqc$sTjv&mTN8JY<{U;3HSZsSWlXdm1~A7 z%f4-RIt1-SXdkyf2>mj=uR`AkGd7O@(qhQsz9hLCzGq9F$dEIVrFgP+172d6e$(U+btC*L`;nlS*@5iz2GJIa|949x>brbq;;PV~hi}9`E zJ;=BRi+jHIaP;PEDpSeDBktY1ci=e||AcOHl@kvw-k)>~?_!ho z;ds$4_9?t7XH;jZ*C)in8pnIV;9Y&nE>KpEiZNqxUlx2eIR20h+CpWs_}Vr>>)^A*3%q)ly2Imsu#3*I zjxlGq>@vqY)oBdfmXTW(zwpx0Y=VBEPJh0R`Qs8i{rBFYE!W;wW|p$c@Zp=Gykld=BY$j9w7Ii|^QelijaEFJ^PL>Eh#LlyUn|e6GavRWe74 z&9(5OwMTVJ;r;L6{i1T}9QULHb^4_Es(FXtnAME{>`v;B3Cds+O|sk*`Wq4;cfeQ$9wAO9YDC?EgB zobz#vpVGzj*Pbgu_if_|F%fyTE{+p;%v%-j&wX3jJ6&7Z>a5!ey8G$z9lUE{k5+Mi z*v2RR8@;QaJ%-|&)8}HC7)k!y|3kyuV>|6C=6V}Z|Dkdoyo(?v^w8s zA}iao<-5CNlGO|ayI z$WxI+BIZi>wu*K31Fog?NzWqZMEFx=Z3#^IZSXVE?*<-cTD&W3*i&(F|(qcStuZM3@4tuzQttyR3MkxaugvrTFh_Yu_* zbYZK3NoXIT&wKC^`Quu}Jwg6H{1&|4bbcH^Oh&td4DB!CrfUrRSbEKY?PQ-f_~Iyh zhPR6EhIN04AH19Yvp@AD>5K5A$ycX{nc67F8;voZge%t$yV55cS}x|fvTCcCfA89Y zw=H=QpX2nAx1u?M4rrU|n?H^IIDPO{@Wa^y?*aBHJp!L>%zrNZ4x<}gYQ1Rgg~_7HZ!zrGlLJ=~mdJE6nJ*(2g*9vW@Gx;ObhckLiNL;vZ{ zBZf~@t|j+%l{^1WH~~GMwl8=6{odkT&H=oeZ_({Yy4{KXNwN@5x&i7Wwo6%nYZ^-xRms}zj?N#=TFfxy)xB2++Y4$C0_AJwt|J6UaSB_f= zpURhep#K~7{yX+P9iO$XZ*qM%{PlY=x0L+WeBnm2j7J)~mW$k;$35E^?TnwZ-SJk( z=!cUt;1kUG^F{b7P2yTl@m^j0&uGL^#;(bq#TWhJ`@6BOr3!CcfcAeQFT?-wGWno0 z_mf^rm;Y7=bxv+~?LvLdR`j36-~3hFLniOTAJI>TJ`bS(cXj(Dd@Fuq^urt2T-?R? zVXF_5qmRsH^me|R{KcZqgFrQ z*UKH7ORdkL&p33yHjh0M{+V)8+D$uXz{lhBOMK*z2H93CKf?=`}gqaf!`+f zJ`9~Zb6WjmHLh=U{!e8&{CoK9loMv*gEl=@8(c-MdC}!Q-%9+SY8l`1!AAHNc3+QwlzkpPSX@6i%-KrzuVk`Rdn9r@wa~(}G23{67^hj3oR|5|;wR$Vb#?yNTe1-bHO|Ij9Z8cNR z%yGUH|55fqqiv;o>SG*di(P-FgLh5De^B&aXcnsPo%V4&MZ1wZGIH~|@Jl*!^XaYP zU5gqWvRUeTggrg0vs-e5ycRl7r?1#*s#kj+d??-6E81*kyxh$WrA7QZ)^&DEC&G=3 z6Lw3N@Hu~`XyUz!_`X6nJEtFsovHGU_(=g!J*UAhhs!Z>uN-sh+1>P9(DE(;y6xoBC)P2>_w%aD z@YSx{@E8-O&%!?~e$+q4%8`5O?A?ACpKHX?M*8)sA3WM-@_C!MYtk?L(1iCdM*m6p z1@y$X_)Z_Zi~Z9}@i~(|JK_IeejFap#$s%eog@weZ@3>;uAg0#qin~CD|GR{-`()q zYiRd@A0|fBKmKz$`nOoC7C9_CK)lg28PTgP+J6P^J~G==-dNKTBgT95EyKn>N-+fg7QXlMeJ?rZz&}{6{`!57A$PUoQS9 zuyNGyJ7W6>@RQ-s^!ly`9`oS4EAwCQz3JI+>=Bsuo2z%ezvF)WlXWXa-d85SR9%jR zzXd;^Z|K;NG}t!^?)a!Tb@_=)C-$CZmY@y0imkvnp74EB68xm=8~8a1{PO9St~@8but z-M~M4NZmHLrhdr_)>nLpzZfjhJKI8^sqiuI)#c(@Qg*94(YHAZ&iCzg2ee%ojp>Ksh<6CKw|2V?TVik?UFnp3+_79S@Umm$jRxCf>Kt*eAd*;u#U9_o7FXW=-R4g_m#;o zZDcQT#MkkDef|USoS~2TG`8qw>*V%mmp%GXd$}(8Kz-Oci#B|SbN9LNUbRIX#oCnO zKjGs)8-A}n4TFz!oe$!9V5~_hzBM!fezA5d2I3p&>8q~Y2m7d}n8R#pgRzz`-fhk& z^?2@Sd(E{E;m^bNX%%@Y+Fy)Il10wxouB5qd4S|k`hcWde6Kbi+M85w6ibJ&(Z050 zw6z)N<=N^Z_NSGLwTIP<#NK4(=~5lS=4rWDJDA<7E%0T0BRSp~N^j?bGhF*JT+CP~ zSHx8Is5Wr1?d7suk8T`%rg(jUkFOS|W0V^Kf5$jWEH_4?y}4{10DUs}crl>O*2F~n zPCOd+Pr)a^*{}H?-5*e=Iru#QpUh^P(4%X71urp~%31NxaZw%pui>!#22nB77dX=$qO=F%Pua{t);=_%?Et?;mU1$`-r9 z7qgpq$=0KjhoXN^e${I}7`|StYJ1tc?6AXrJ=-kP)*=V=m5cRhF)oXD&hwSxXeqhd z;FHnX$A5C?z2weq7308c8cezDT=-h{Y@k;@Rp;tP*M20Igp9ZF559A4YvEn+O>`R7 zDxNQQvqd~ZNe&Q;ef0dA97SH0Eo*XMtntaVqu18D$*ZwZdmj8;arbTb#pu;5=Gm&D zlQB{DSL}ay%d@G!@P0q)C4Rpz&i|RuTI$scdp+y`{}F7KGW^s<$NpjXi0kyn@T-iy z>5<} zK*q1x$Jn|ne4=jEePGwx{sF!A)8yOY{^Q5$^jSFF^W*6^obKThePZJQ`+tLz74zQB z@cE5&r)xZitlp=N53(B{#9ED*`-txi$2@OIyGnN2JOe0=#8aQ$JsRFa7kDP0=C8s3 zz&3QOo-95CAH^nb!T&^0HZOH`@bGsxTUV1-G3TBAPM?iF#=w$d@&EM$A35L17NO5k zE%%G)#cqvH(znsW8}vDSnqqs4eV4s@mG;9|Tdgm5POs$PmhrReCv^0v zxA2MjeO$d4*k9B#gduM|x$MzCM0s{@eA>Bkjm^%(u7`GQar92g=0@!&m5X_){yzLl?9aucm!JXr5lx} z$AEh0XW=t0VgPiUFs>9F5PwdsVDt{jRey)AJ3h%~uu8sc1y_tTcIuw2mxgX-S z!L?)28#m<7!O!Kt)qJZDOAms3_cv9K{5*2lEOjm01D6br*CQtlk!K=TT*x0`gE{!fr{U|;8D-x#Qa^ZoDthN>z5 z=^OCFVf)bsUQ^fpH}R?AaW4G#x+uDk^%I*_;%ule}1oaAg zKg(v5&=1!imEm>xQShI;w-gHov&DA+Vs0|-e`2iGy$J5!GrOG3M`8Mt9_A`_cDEEn%AcWb-V$I)Jm|BbM#Y`5U~VYzq*yc+l;Xg+7-%ZfHy9R`C}hv4}bUJv3q zo&KYh3;NgDu z7WMJYM=1X_G}G8A=vS!YlycFxWcT1dn=bI0I#y@XN4z9g!xwb$WK+5Lwq^E~vV-wi zVvoLyZQD!9eUGe2no6a&qY%wg|o1BI(5g9^cz*zQz{n8P9XN zSI`|E-&2outu=VeF%^Ae;Qt4Iww%=A(J$BdEx*!MV!H>*#>{Bs@OU=Y2t2jFnVka< zAI`A-f12(CPLBG#AOFxhK6=MX2ho<^Zea|f8+-U}@pJXXt%eO+?P@Jy}Jn}c@msRoT zpksETd$oQ%HSfvvGwJjA9^@XpjZg6}+F<&I594`7C2&58@ndtb`&Z*>&9LZXOX!Jb zz1wj*9&5DcfwQa7Ms~2N(gB~Sj=|eZ! znD@(~Uv7U*%X|payQ=#Wv{^rZ{w`eEp=i$Zw zv11Wi2Ptzvt$e>MzX<&bzL)dax5+xAR=!_Wfye&`sPa)=9;DaS%J<9i3*jrRwTW%z zVP)#I@;%Z@;Awx3M1PaNP)q73)yntN<2xlCa;<%fe0Z}~e)~V>&S-M#g#YLtV?2>>NJ)$o2ioz{_?99?zQ^^T>VLy>sDTavk(X zw)Ev_Gd938_oKcQ`-d&H@_*dNGpP=`Mcdon8UKRrBk-w;)o6OD6J^e=ua)1Js=#Bt zvAPca*D=nmmHV~oXDdGozMA}&IsR7d4Z8d+{1@Z10{t}O1MP8z4ec}0uRtF;H=MuW z>1_AlU29w~pzlNI@#$FL_Zjewc+P~MFV{9}hl20v^xWclGJNMx=Tytc9RSC(%(cn( z5wI&`bAB2=+wqZ8@eWrPtZx;2Po+2d*zX%_{T&~(YpwF9w8}A0_DbmK`7S?q^F^D_ zo6hOu@IGT8Hg)ht`y{_t!*6#UU`!*93H{n9`n_CSu5#9Aw#7YXTda|HvoHKn4A#fK z<(eMlJY~A;!`Nkgp?Hy8dgsUS=Xm^g3V!f=`D_Q<V;8XY(`VY~60sdf*=MwZf2!4?3ot3>4K2HvPLZ1WqaU@%h2wM04 z0UohD0Dr#8&gPRR;h$-h-_XmQlYLzeg!6G*-c;wh-e26$aNL2G|LRx6XON3F`8@m^ zwetTJL>tvN8T~$ZJkm<)nH(BQ@8{)N)Zx9*4RGRR zOq=L;hfh#;oP7EKK3@EkO?Sh8dcUk{d~(s$JMpoe zlWkL0>|%af-^|7{==sM_$7SkjzJ%PZ^f_8N~Lw;jr+3)KVbV9~~;@QyX)DI4!>W!BA_ zi`csjPS>`w<$Ctd)RS52-WEPyPmdn-9r*l>etIGsH_?*~@w_6wADWr-B%|>`8~Gh$ zA0JDx|COGD*&HArTuuH! zt}$IagX?$?Ej-a5)b}Q18QLSYkHc$a{f{|VJL>cj_;=OWL3~4>_;yd_bi5Ygqwdz9 ziJ0Ki1HX(;>R-BAU9Y2MZ}uwtZ$LlWd8g3B!9U4<^572i)8WVaeGyybgaKKSKiu-H z82>BL9>)*-pR6?=P?yTJW4zmYqu+r4SB!yQLmzY4@1WDRb%}4hcTgFE{sVfkDfZyh z$>dwix|Wkb=+>1<#2z1Q+h3HnU+`+`p6WwxCvHsjI14WEbqT$)ZzH{X|$J(x6!{zcRDBA;TQ6o zcCmL1{;%N!PvP<2eaBF_p#D_8O^%$a27Vg3+UtbRV?8>TTiwC`c=+96Km1|19O|V{ z`K|gIowWt+uhNT+O>IZKcV9n-yya+a#lIy6VekIz{Zps041Zqdz8LpyKx3Yo>iaV7 zNE^SdZhY3k6LP=WS+=3=bKx_|E%5oH@o(P0?pm5_Mf=#TUZ&cw z}`uR^Tty6cJy<^ayvil#*SCWU`u?d&&3?eVT_eBZeE z08aP%TkKxz`dNNjV>~d*^_|wi)u+ZP_pU-~Ow$++_nbPOyLW#AKMi^&xzK_t2NY55Y?t+!zOc7JkiTzPkW3w~1%Ny%EE+;SXr1 zKB9~MC!PhwJ4i7vE8nkIHsP1-7`iWZy(fI6{VwWa>^*9q>Kr+EFn@n&E#XzyyQ;_M zvng`1w;#zn^{B9tZ?eRbAlC8$fr@)Vv3%Aje&tq?P z?D>sn(=m5T?}71cA&27Gbh@?2c&@Yjo>mS|ehL3ia-$9X37juu{7{blc5&~&#dX)3 zen6Xre|At!UkmqKymFNKIf(w7(4T-Na_`vAa({Fxe`6jK>)+Xt^mxy`Yt*0N@QB4# z@Z(`WMt@hWd_Otni|t$RA0U7CcYYFo`CeTJzt?ZRh%sjJYR%pwcBymqhToT>zqwX^ z3#9i$^+TSP?@M3Fp2yYyQI5%cFs5aX5c(TBtvk|XoO&ppU37{*I=cn_j`nB@8oVP$ zF_*~h)SrC_pM`!dnUBJwpSw?+G!vf%^pP+1FS2W*vXAKx7uL#mhI<$B&qa7mgX`P7 zKS963@o`Js+{3f{o|?WO`phODR1$S$9-gmumhYrj*J-QN%Vd)rU4)N%A7iT+L*=bj zxo09@3Lh#i+qI>e9jnowAb-h9-^K?X{Y0`B?paK`1s}!t@V0ipPpsmdi#~c}WB3#Q zF8A^caDMEjLpBaQnIWUPi9g4)-|u+;psiejPjfT;uhG#V_S9C%@4~Bl3;*mv2iL`P z<=D}uY`2`MoX}r3_7isVXFFuc!Q}FO-HQ3_;@1ipmF@U{MJ(u0EFwpIx>uryXL2D6 zd?~qa(49{Tc;6uU2EIakpCGsUQ~YiE(m7kD{&b=&+4T?blUwC?LF=np<#?bn1E1gU^ELE~ zw!(O*mk-*L@%d-|c>v#!mDBem^a-EDw*|9I`N8VnQt=J_$H4!(@se6{B> z=C4oZx18KZ>6-8pd*c5y#Ga%2`><$pcIFTMO4X}kB7P(IFTb-s~vpg7Pw6_ZNpJO-{zdQh!gQ|%Jl_WAJMp8hf2!T8+CkJ`3aBg%Kh>t=a* zNUfaTL>rbI%ifAIL*a+g^GNh-U8XvHxat zV%Gi!e!qtw!LBZ2adj@f?biJoz4k(LBtC=nYhsYC7h|!>$cz3g+Q4W_lDR$QKEqhE zNRGzmc5QmZaz;AwBPK91F**LC^Sp zS`q$u*r6V_JH?(|@jRg^S7VK%Ua6Je1WhCM+U)u|{Miumx5)Pjx-uSeCKZp$@wKwv zly%WqrFRRNa-a>*$EXA1(!Vc9-FVNv6Xfj_xHx9VXp`C39`E`@@ty0K;ra{g2tTno z)?Q<6qa#-~>wgB&JDuE<>EGkt2XO6NN}t|2c)>f?IbyuUPvux%{f@DEueKt6lRk2} zU`OO@JWuP`#)faZ{(E`(37n78(`w~DknmN!6Ok^Glk&a|@7rFp$A;X!)CK-;uf~6X zYkcx6T+6XOy7%&7{d7LwjQ;@FeAu9GXS!adjP-`>D!M&^R;<&r(9cKzA^c`E>QXsJI2$&N+`HxO9m;jmi?4Hd*I4*} zsc#R|%6%1Wc)d#J;qYI2ru_svcB4N$IS2NN<1OR07x4T`xeYJi>3x2mpdEhISvwao ztDnmz`o(v8(!tJ`v12QH&%@(HdUx~jFxUQ9OTHN&c#Qi}{YG;n{5tLNMtsg!*7z~s zU3}h!pN5}yGxkDd&*LHAW8R;Rlq+lSfhT{h{lG7ufMyH5#JGMTe1Gl4b>fht--(?o z@VN-hIqL1*;ue0wud)mucrr_uTV?#A0Zz z|GR0Akw1%{FCpvvR{1@ed=XyZqiL=Fz3I1#^?1IJ*NyP`B;_x~^Ql(SF_oUn`9$4G z$0~oBa)Zg4jdrDj4b{=~?m2J}1-x$~`?kOw zO-Ftexv8`G$2cwOS(l@IUaNeMwAhZ{c5)nvBR_$?pJUe!cKKb|0Yjf2r2Lia`INp5 zuWaR)om=Je{sjNv-S{qgbv*g|p7Q_E$C_b`1AE~K+cNXAm;=OGXq(=B@EHFV>P}@K zzkI?D_68nf=9rh|@cP15`Tv!>gU@2e+32rUZU%k%vkP9I1Mij7@b(0D^M5xz+f(2> zn_FE&k0yP^E834n)Irb7Vt&%Cj>of-zOXgwfahh&wQPKh{!__eYuD@aIbTi(eiQ$X z$AfL@Q|P~?J%i_G(tk9Wn_SBLvEATzhsE;xC zh<9pIb+iqSwYuUM_vK(RiLHDVdz_M9G33tu1ED*xw1 z;N206Uh5N+;1}>Y-v;mSeXIi&aqen)CiN*k-T4msy$^px9apZ7J`?j;*Q_Yt$mHrr z{r0xmpKg<5Z_{I#7|m7=eINg3|HYo6=*G0{QzAe7wgEqNA$@}mlUn7T*YJN?*G_Ad z&nn`1TE475UQw^gIsJ3^ce}rm&%`N>`g2>$|5t=BqyH+r;RX8iVe;-{drQu*K^OQ| z$G4RKit}3hU9ox~#(2J~43n9P>A{2|e`C&c|9|+1Ij)U<-d`lqP2Ra5*+YSQ z3_YT6k8xVe%ZgjjUPQ)C@YB$quC3kyuc5t4Odi7L2=@l`#c zx1q0V8;`*!<+s0w=lE}jj~4%tqFf z@~b|lRlW}zW9j^G_`~x52K4js*?`~v;wH}V|JY)EqhnF4eD0SYOTRG#>rhd6)E+Lqi)mdiVJt9^M(ofU`a@L~MALiu_0xtU%EI{#MO-eq^o?`iD(Dme$x zFLG>x->dx|kM42$cHnano>S?$Mn1gb_e=Er4D8FW*^YJmG*~{pPR{=LK10Wa_^8A6 z=javj*iXH96YWxToBU>9;~nz%!H+I&_C#K118Ye$>dxMD$ zvYvmo^~>v!ADMV`ERhqN$brY2Zr4or_wXC<=#!!?OCvYu@!RhB%b6JeRd=AjC;D^e z-9kTyxMqR>g?DV_Ym7PKd0DjidEgKG#tgsaJHuhsPwBJW#GGHVo%mr#=on|ezomaK z_#>e^+H^~lqt9}=v(EkhQ^#P1e67NZ-^q=Qc*@Er{IT7Xlo!#2kzK_WW|33f^Ilm`&I$HNCXfM(}xE$!7 z#ZT+iiy>kUk8gf{*Vyi02+%Y)Y1)|116+1^=l!Hra1@?;`ZZjKvRoJg+934=VUn=PN_Ud_U)JqaVvR zZ0aH>*0JhmIUnu*dvsK1qFssa6tr)0ep>sco}_edPITS~e++I8Sq!Hm-xVJyZw#Hh z4&PN<^A_5V@fzh`q0Lo3p1(8(DNmQGa>*(<{Zh|sqCZTgEAximE3IjWk&lX&-=v7S zL+XCE+}K0?NVRb>mh8hj_NZst&E8MQk(c?`@FqL@-NHCHmj}rij@!_^==??a0(l}9 z(PyXEJMNV~uFH3lBagofyWH<0=!& z-V2Ry>K9|^&*vR8;Xj7^zH#|(;%K=1>*n8dE&QL&rH;eDiH|tvOYonK_v4+$Y7sm1 zlVEZzdk5cc_`C9NNT>BR`pL=cDdqnOew*>sVD)aW98>3FuV?*$nl%$L%qwDkTxCP@ z8GO#DmCr?!m&NziPwUPKW0`Af<@d?5=h(}SeSDFeMUOgsW@owoF+GUxd>!j)$%}Bg znLJD%^Vg`$@&5>-4Uaur$-Rz=72|b_Y!=r#OK9m`}pF0wttNNdVDT}uT@@b5z}}b@=J%Po#skB=%@TTUvPzeuC?P+N8z!{0X1U@YmpUF7B%( zeRIX0J<*T%y#+rApI2)A-`qS%x$XFmgzw`y%LsJpX7X7);Jxp;w=?>&?(56y1K|3B za-R0@`tWJ=d0S3g;Qki0lbwGV@7-&Y-hiiQ>Di}Uj_=>qm+|=vt@7J`IlDUM!ei{c z3f~9W_2pXm{zb1gJXs_s*3+kp-^8}Elt1|{nJji}EG}zV567FV`X}(FHB!1IC%4M) zhWC;pN)$>-cEbo9q1cK5mLn>?a9-r9=2?Bm7X$ zdC86aCmBB#-*WAD@{j&cNR+!lZoR`F_v=siI-YBn^1tuhlreO?fsD}aI?utw4sCST zIOi%oLyxBaoe}<+=Q;C4d=6%N;FRAw-~A($iZycr+Du9 z{!sT<$ctIdFS#Fl+WPqy(fy;m2s!$Wn8!u?8_!QF`?wCjcU{`Ar#cnSNwVW2x8U~a z)WfdNqd%HG7qKVoxL11|et2|cIS-6EYxLXkK20V3_PX{)8=Bn9k8(WTg^e*lG77yq zRy@P+;#BOfZ;rUwgPOcAmV@NZYJ3K?l8%UDd~flMXm8+hr#(==6LGi39?u2R_xKFo z>}%SP&}*BvVm@5FyX0W|xt6vGZr?~ecj%V8F-A$=Y}5~yJMu*i zC4az2uJtbDBRO8KsZS;+Vc(r}meU>l((w`ge^xzMVd00WW*v}PXk6t#$ zSgpN;EcGrs2%j%6Zwj=+|lI;jvy2?PYqs`xp0^1G$D5WHjK} zCG@PZOW6)Qdikg6x_%n`D$h!G&;e~u*XEX1`7Pw^dU1v~`MArGZNzg={J$V3ct)M+ zWpjNk{{JdJzJy-cx|nykS8at3ou1dDXJfGfwnq77==G&Bx5-w!7VC;K?FFv&Ey+Xl z)JEnDU}6w+m{=o=cQ|^{RA<%ni|~o`{PSf=qHpRGyQo`ju}t2B->2<)4*fV~$Kg*# zeJH%)ot$s?_@)j0^%K=a?!643E2mzi=d0unfstK#2!2eDd9!xmL3}d&=kV$0UEfdl z!9C?ZkoGtDcqX0BMgJZiL(renviA?Y9IYJ2_GMx+9IxG5<@#uKa;^M7Own%_Z{xKE zue14Cu4g@X{104ToV*S{0zMY~YxwR@w)QLj$4Rma{pDpQFYBM>>-XUke5>F`+KVRo zCHnBQ=`;@i<2uX#8&a;R+j~^Mf=(02(VxX0$LLFw+m*W+J_)_CWURHMgV;OW_1DOq zf`13PuFI14x8eG*{Bih4bgjbA)!uv)zAw99qR&kE*+sv7m3xH$Pi4pJaP3O+1p3F7 z`x^gTr5%dcsO!la=<$p7&3OLM^$vWY`XW}br@6QupJyHK!jsMbtdZ6>UWFJ^G{X8|!+0|9Bu#&(ps|AN;i`#e3TRZSrmd{IBt+ zPcakyw>rzc7}+Ma{74+>-=)sP*e1s_-2(p!diJKF^AVk88x-Tw;^%TreTa3vq&J>d zy4FS{ztBF+LciMe-Eguh+sTc7ApB3a_`gxbTC(8TKhjgY@{MqPP~PgNFN}Yi;u|_7 z#(|l-TJrxldGe$%U#dxFB|&+l5=b2yI=90yVz&gWN+~& zGzVxG=wMQ24pS??E0O$pAEZQgh|BTN#{vt~flw+!t|LZ&XeTw*P#`A~p zucL3lc5!UJRGgFzoo^;re9PzO_JK6lD2pa?uA6V`>U!CxC|lOs!|5Y`l0EU2E9r84 zjtsrkzq!tP@!f9P^F_`lxwo1QVp`wF?~_~Q`e|N8_d5E>=l$HfhWw8lm!UbZvs{;m zezMej>$`dQcT*!z~Bhj%A>`Q5k5wfYfr z*7o_ziB08xb&T#^o6xM4qZgu4C)>}}%005t9>m&7dXaOtw(Rvxi@5$PZFB^5E_Mv386#Iu`cpoLw3?J0_wbvx85I^ITsB|5B%1WG~-Tb9P<@ zr+e1jS?-w#pEdX@y9HhgJv+T4fqn~L4MTq~9WD>Mv_H#h<$LpahX0dju5iDzR?Yz$ z=+j5w->Dh5yS@wU8tomtB4_hA_z+%yfvp?(jokJH?hUV%?}xUBz&Dcx&*go6lxz9k z9D_%+>Fi4WS3Z7#|5a?9g5Ngr;)j^WL_e3q>vL;mU($>m8KS+LO3#6jS7dI-Yd!j- z%;W6&6!+Y1FKhQXM9zR?ycI()Me`O7Py7}Hha<8E;9b+6=w)OgqUVJln18ugE zk0P(-Lh}>+;nf{5ypqWAAIs~IcP;wBSJ%q@li6NuFX#=g&?Uxh$x=Q%3AUEJ>*zyf zm8^6zniR%|8N6>jYyrE}i{wuDCS~9;UhHOLte?idyzbEZdOlx9=4AY5qkn@>@U6px zM?8ycPKP)7BYY08f8_iQ`l)OghaWo&cwCor$7lzp;sH-?z<-46R~+N%Fqh2mAw0&0 zmB;C+o#|dIH}J2+(+8Bh-97D6o!xc%WWncZvaX`X+w|d^g#I}^Ihp)3(G6u6+pByY zczajp!`yoVPqZEG74U3>dYSnBYO9>9M1S87{1yHBU#a8jeY#!QRsDGReb#C4C)fqA zuly7@w~X%^rVo%6KH$gTlMIl1`;c*mTw+r%+X7F=!v~mWe}S*Wq#6DVxox;-X!U_; z*74!&Px%~(*P-(D5;$Fw>HT=24N8pDD`&ygzasCqE4jt*+1iNX<%Zm>j_=2dC*gmi z?>vk@XYu!YpZbSi@?FT^hh2D7N7nlNJPN(~mg(zb-$?IUY^G=IFHEcOUmHIR7oXQ^ z<$LgrS?*o!-uLt+uj27se|+1E(B4T`W6S(ev=6fZpIDd8o^VW5=e~&k4tB|(?lrBX zo#FKcn=cBP^n|xJ;xQEc9c<9%v@caA=(jrPnw?^4*YVrFXUzTE>mw(WwR0DFRpbl@58^|0Ut@e{wdnS{4?^5k0K_` zvC123r|=jnC!6s)rB=2P#UJq*h;B?z`9GNI=he#pC6vm$3?6g)l<#6MN!McMqv!{Z z|A`oVZ2NkA_%PO@i>sWio5nj2?b~YR8_t*E0go|Wg-`Q)YW?4qxDg+CeABi)$F&@+ z&?~>waV35B!1p8fpTUP6^+#avm~%D5*DuqDKZ@tc_?+_B!QVskI(m5TYs$jw4{1wA z$dw`Z!?PiDglDh9`LXP?@o)28o+|ftElHo{JN;SoVI5=ehIgp1*}dq;;J=Q~$Gg75 zz3KG42~YRitMHvn2Y5VtEiQ6@QmuT}68jhn@z1mg?c?B6$&Vakd*nsF8vlKi!!Mmp zZ@FF6=&`}?->9Rr-N(B-#$@p37QfB^D)aGsAHE!Yr+eGc57P!Lbf4W_VpM@g{}uad z^CkSoH_g>#Uglol^NpLekI6S${qF?9tK0lO+`8Zj=i6HS?~Y!|Kb!ovZd$G*`d#It z81J^%l6Sp2!H(p!=(o@X-t2+DYrMAxzDs|-%`T=ZJ<8`Q>)|`(>^403E1%={@6oe6 z8>P(>vlO0Au30awmES*(Jz0&2<%Q_Amz9&*%pdWcn>swkDebpv<^LJTx5BNrw(+mP z)92t_wQ~I`)-Mxy{e1Mdrvwwl6{dg52V#tXB?4-8q-+Bk1!+t^A&U zu`3-mw#xT1V{dx59BO{IRz5F}TuIMyZ;r9>`}hZb8J*UUTZJ2w#B==;|B_s3;e9r| zprSLPAxreV7`%Ta4+vp>=&TxF(Q?7l+SStHK znfb8O(f_wxXwd^6W3zM?d;ng@vQMmHJkdRyUDM#FIpk;~V#3eaBKIbuJr$p?%CXJ( zIJ!Qe{|E3>951nzfYFmL)k!PL)`n;;WL~cPI6BUhwtn1s9TQ3oF_dI z{S^AZD}^%n0)a^pCoeR=vMi>w|f;H3t(($(a}L zC*}vy@5om9J)2lh>6l5j`|X|aIXrxzZp_Djnp`0x_M2vh(SHj*Gw70w!EE?ib^K8J zucr^JF@bGU*gMHs;$YY+yvB;3*yfISzb&3M)t9-~td-9Yy5vK0o_78K`p?JzP31?@ zQ~t!-bIhajY3%q8`k{1u0)Du)=l$^+%O8*8KN$99$FA1e_G^{jACA3KjaOlvj_;{s z`=EWrz0djmqT@KnS<#pCg*ent@8@0F4gQRFe^0)+%DqGA{s0;J@Z>}Ed%*cMzI7O5 z;C`KXP!rw%zEHq^t8^EUFmFnCS-bv zSKHk$ul+^*{~i8II=>JUpaI0?0fLY zO|eLSD`vOCcW-&;9R4_a-oVEgFMCQk`B+S3|7-GQxZiT9d$6WCT8=@cBFI zRHOO+TIKID2YO##%9Y}0JxP5Mz3=9a0q7@--$dnq%--3~4S1htRkQhhDm{K^KDSt2 zz6!U8vh2U)b$ny2OMNTr_B79GmdovV32zrt^8OJ%!EkK|l!|5p6f`&hS(eyzJ3{r&xYMXW`Z-``R9 zv!(bBjk4@rN3J!F4*a`U^4k=)!+STNTMpl-T!H^e^!T^w7vJ@6;#XOZ4;xc$LbQMJ ze7jp+?xJ7BsOf$$zS(>{7xIrfob80iYV~a!xpU;h41V5*Ufrpz(B{yum?6IEb3u=2 z6Y7f{@N^m-)ZG{d)=#AC9A)6~{%7|f{L$xU!}zJ3$9^cU9)siGzLZRLHrldSC$HR8 zE8hu=v0`H+e}?|Xdik0BK>xgpjMueaVee>q?kisGso&*ziVXgVwa3`25o;vHgUa0Q zSc}haK51H)=xOP%+`HVd8Xxoc_#fTrGWbh$I)OiW@EI(xtgFVFVLNhYPi@h1etQl5 zp5n*Od@x*|B_G3ICRhB@Y4jTgXJ`4m>;;(IDkktdoBsdz)#JHIWpDPs%fF6foV6Ez z&mM)J&X(bP@B#c(&ub^w?9<`@8{mWF6JE)2+KCD1ze@fiV)f0Ibs{zmvpz5e{$>7G z*Xs8;=#*|$XT*sE$~~|JUdc>l)cM%Y8_%06_-4i(O|}(B)ynTN#ydNe8EpM5`{(n; zg|%{wk#*8XE?4$)EW%4$5_7ZULiOtt^3?Bmo)+zKJRgm5P*=o6p2m7)Usm%hhul-? zvw>XxE+W^4i%AXr@sa=dYVXn(Ke4GU4`O|#TTX?)^3{Buqdx}yASFm$+Dz^HTljpPo$z>88s7{k z{ql@By`+x8)6MXk;Umz01)VlG{M5Z3E?=XcNG=xhk$%HF3g>z7hdd)VjNb3lA0E$W zVlPlUzm8`K#Uym}j5UdJUySEZ@vV(yhPuk$bgpvpJ2@1dqMrvB&tfY1N5cQ)S>O%U zbd}Gxuwxwh)8Xsk%RIx^W>q({SI$;0gPX&}|LU%YN4^pNH{@01u)Ut~E^C)M-d^7- zpMj-!!sEHW+>SOg5%>H~d>(;U`1CkD_@y2Bn7V!*ZvIgD9NqXaU*-47{HOjTuiNK) z4_)}ZD$X(2Zw`SkzqEV~*M>Lcd+h&;ce|24&j5$l%JslPo1RRe+ZgAAFDu8i!KYW- zncNIl-xBKqZ9a)MIr@fVLDYBW&&!GVFnZSK`TalkJIru?82wWG+0{48@58j?bDfPN zy7;ES53wE|^{GSKmAqpNB-U{+pW?T%M?<|V-*4E$_xv2=u+n{^Lo3--)MRdO>=j2zix48&EPV*5lzg3w!oUi-f(T>Jgw0*fUU&d!yKc1b6 z&v1I4MxPtm#0OD7vMT&Lcpn5$@m|~NZ;y=cDs}$+lXm$67+bT@Gu5WZt8^>*1K7I- ze@AvLI(e9mC;wRVX!|z7zkn{m|10<%ojeWgrk?VfgSqxNc^>~OU?c% zLFN+pE9lkhVzGPr;>t>G1m9(Y;M#$5&-=IVN1M)pzu8%?52kx6djtA;Vj20UUy3z` zg00!%_)TMTuFS|z`-j;mpOWv=YdpK;T{@aR&jb(0ZN|dyz~64y~C7|Gv(Mm`$#KtvPq}1za%f2CJx(`3m(c8r`D3}4|L7w+6&hn z6m(6_S3eHH>oW9z#s72<*_A8QnRDRw>Zd={p3Q)Tt~cOwBfP4w_;G(bSD-J~*&cxZ z)c->HIenV_d7o)tV^2e}QGEW6y<*i?N7|2Q7l*(H(DhgR|A@HIKY6B>G~R_j#nyks z=SDGQZ}M`j{JwD^7s|cur=w?Edk|dv6wmUCf5m5Sm^{wk(rztqkIdqC4*grKQN%vJ zOxqIkve+-33^PXQrt3Sfo?4P02v^UOz4%*vJI2)d-$^1*Y>K1aZB1c`1aYTDG`YABuf%YxvcXs@MeZyMidVRXx@B8rD#d$yF`6!=- zKRn(Ejb{|?Gu?lR|L4(1f0Dd{pWMi9g3J5z+5TqNgO%9>{uq0%lM{>iA^OP?j=hxS zuljj#K4=%NN8_`%Hkkd{G4%h$d8HVP#b+Nne5l+Wavj$3G1^JW?dy6byxmi_-`#X7 zrn$F&t$f~53Tuj@qOf6-zsN$UF>kaTh2teYw%c$ z*P&#=3v>KTOv?2D@{7aJ_#O2oV$!B}%tzXXqj8S-x8dop3-DNl%J9>kr(udcU+w7$Ku1^=_9bf|4*F@ z{Ihfvw_Lqygx$}|)xazKApTAMiuqp58^bTr7Zp!AH=X&R_*Zp3>^@%E+t7Z1SNQE9 z@pr#|0-E#j&gsL??Xz0te$M)dY-4LagB)>*=O|rqJ_-FV`9w@(o>tt~D&J{MPsURl z-L-@+>&W_6%iPWNDXz~|C*@jo4gPP*$Ck3E!uQush+V`lAKmhaCHQIZAD9#HbOT|9$qE_4k~*Rq}Nm1T6DkA5z_PKWQ}dMmkf>d+pv*^-}u&xx%v zMlpwozMwe;cBXp+(ePo6aq~6mf!wX1h41}rfp^WO>xP!KA-vC~Q`RcSM%~lVY(c~4 zl`3D3XxV#%7T>OI{5G_fwBhZst^Pft;#}WOuKHP=qs&LH`La(t6L|U}ea@x#0{rL54fWfd{3uBP*HJ{W<9p4sP};T6{nxmX=i>;E5bo$R~7 zwX$9F@O&MAerShpE~GylayCV~)AunxN4tL!UOUj!J$Y97`SjVx^~LmqH~6H~Ph1LZ zZ2?d4=@_YOovz}T>2tD6=(o9*R0or#4%A1Z@7DHT%9i!6*`D9vc+2$&br9ZewUYE9 z^jDyNsa5V*i1q9=V){kru<<_l{^}mvy5_;1qaTcRG1iTe8hxg=>^DJsj524z?K#Qe zu@2ns!21C72f{rst6xs;P;&Lv*>33nrKjAFpU@$rL!o~z-?cHGO1^B|1W&&TAA??B znLOR&8$~_k`|0(|@w^$f*72YESZ!wVIeHum|EW4K3*Pi?vhU*qPyfsP+dT)-pCtca zP5wXdx{}-_@Obyl*sq*J?MrWX5^D$NIcn@*K(C`)@eBO=B0b@Wm?d~-`heay<;@^^UJJWZnL0fDD5o3z-iFtocRT|( zmW}rg`kLskbAJeWWz!FP`rkW<_`$2{Nj{w1rP_)4^65h~*UPub?!8Cn?}~@Eq=e5e5>}aVob6oG!UthZyl64~;wRdgTG0$#q zMlUbx@YHp489pcAyA8gATzKv}?{O^$qp#?0x&QfEe?PIt^`+_=JaJuzM?X-g8}+N< z%J!{O7T!eDy9wTf&lcr3(&r|$uB%&Ncd36H@!#wko^7D#Hab4&U{{7F#uTxB7URIQ z!L}a!(G)?yyw<-SDBtT#>9a#Qc;5p0qmQvm#dXyG%FX;eT^VJQHytz3e-}@9dmQ>L zcn*V4R33fc&9{}If0wvc-W6AP9iG0bOxXCYc4bJ|*^d_wDyxm_7>oYf;zU-^M-J6j z;whHN1N0#)@a&XY`EFS{nO)-A9li?s-O1?2<0ECN_-t?vxz>bwXR&dn<6v^vq6t2) zsZ&#wo!d$>eZ@BRf@Syil=}!X zc>5f`uW6O@`D~7QoZ|yezv%2usql5P@!skf( zMb6>TzMl@)@q-w~dQ}_VL00=V`oW7eLGL?-JN%x27umU(NA4thn8(Po`bT74pv*P+ zlb0EX)#1fF_f8@QUQxc7B7X4d$7m16=jvM7uN8mMPoLAF!uIw+{LyPv%l~&!p+ou) zJ>_>i3jAYS+3kLF1D@-|^(wi^U&R@&{hnMb5^%qwUYpa|Kyn z!5{+3qy7NijceYqQJs`kCyN zZ?V1}W9jIZBd7Z2<0J3OwS+=W!|NUJCG7qL@0pIjhhKv4&*a_;eB^(}B>Z=VU0m}% zn0eVMxcU<9bb1i{BKjOJN7gy3tNBpJ31nXAUT**9MsmeAds(c`gI(bGqqV9n`0!Kx zA@pLBoR9w?^0&F3-YUOs5^J!DGO4+A*LHlSu<2a)?t`C;es?(mPbcDkn0pPss}Ai+ z0k4dM>yz7J(LS4h>s32mNd4hr`dr_fD-l_dq?;_Waz}x7>E@4x)8vnU;`&z62y#qAulklQTFB!2Hqpp0i49$t? z`M5!c^!ir$-a+pQnAk*L)*OTX<@ns~_bM`vk9Hl#w&Xl~o`ta`Jq`x1zMII)w z);lWh@n!xz`lDOrdk2k;^f}hC3HE^U@~dk+zI<3516zmw3;3wFIUjU{&ySV|J`=UhIKLz3o-o&?V{gbk1nqP94aBDte^^1!m}6ykz&ES2dz)k9 z(Z#>*1N(I^|L?^j&lG|;`(rIArEBavkNO|HyU4D0xUX*t-pR$DhvLz@7XQz{=hDSk zG%BoQ)^*eOtcu(CPo7 z`Ta3n_I5AUzD{U)Cfr$mf2g~gj6K~qUTt68^1VShI8$8alCcLFe?osV`o;K6qT>wu z?+!nv<@=+KZPoz!HpXq0-SDclyxRrWj?{;dIRUl{UU$P@cbKbpWO&HEXj6(8Tm5se zy_A0#ebcd%dqZ1dMy^;S@}*atqJ8gpzm@cU=w1cS^OYHd{(tE_1?@+UGw?n_`4{m1 zZ}Fb1{c>a%_5Y4%W3i4@d@L^)!LL+)4PEb2#$2eY)$+~nR@v7VgZcdP${9On>)<_X zyIuLi`rF{x%TwJ<=9*TL-xvCc4f)x>;I|F^dU`$%_a0z=G(P`iT)zXhsb%dK-Oc)t zeciX7mcnBkSm&E$N>BN0EB%$W8=vSuqVAcMxPdYkJ@+A>mdAph<-kqTO8_c_D%R$ zIZt-)v{pIRsLF-L8}N7!Mt_`-r2`)&Q)|{Uoc(Xv*oTph;OEt?q!r#4^c0(>HlQJH@q9knNT-4FWC8urkG zo;40(!>XEoiLVCGRb7aFyOOys=*h1!4@kS=XF0wBUt253>G}1cD?jLm*-IFFM*#7N83G_E+6y59%uqT zR(xMqekQr%P&wh#+&UEh$M9Hy-ch+6uh4UU^f#lyBj(u&`|5Ho_CjZT)1XT)Ua{s~ z-$2j*)z0ll7MrRcIg`~P7n6UncJm`Tg>UE->q$}flAr0LG6H?Z0DXbcPi{o)cKgp$9H?PH+#xCcK2|6 z_`ER>Ztr#MC5qgvyal6gJ`=8Qt?$V`In@~C-g@$EoMZS+JR z^S78sR>!x>we(o)NboPd18-hZe)Av^!$Pc5`s7c-eaowFL96`V5yg3U@pt83xaab9 zb-ZsH|DWnurOYkx4_1`(;3nV4dRjAb=qmX4y+rH(fw3z>Pym{;rGQ@D9-5D>nrgqe$i9z ze~5Wmc9Y+8-RwTCC*j&3i~~xkN9n8e}8tYa^FY0iJo)FjecOQy0|lZe|XH-bNC4O<<`?YXNYh1 zCx7qWd+^Kru2}Pv!|}b1_#R2uTlg<`eG3}(GoDe$bAWg*9{V85|LO2Gc^Ufd*5jMu z@GWQ$?2L2*3X|Xq;hbuY(Qc z9{gFbe{*ksPx*dkIVL>`b}gFy<;q6&pwjC9KTc=BA6i+it;hd{O)rDXqu4u_%wqqg zbox*Dmz=M{|6+Xa?JVDYN%w#~Ax5{rFNZJHM%@oPh>X|Z_rlo|&t%f)#q8Kt|C+>; z@Eh167V&;%^rO`elzSQ81;1P_jZppqZT4jC{~Pcx)A_4%U`(t0pMYJnTjjeeF@~+b zB$w6A?8a8P&#-$G`up%1>iiLVzD%yVP<-I`!)%G1oa=Y}(>`G${0Y9AqRuX&&tt9r zG2T@8Gx*G{i7!6lnaatEI*=G6a7h}?DB zk@ajHf`_`0FRhjD$h6hv0^fqoWgEZ3@3;6(n~8MtD?!&hjM(%cc;oIbks3Vm% ze!opmc#P|caqg{SGkK9WUHD}1UOwtwrHr;Wdl$Z${`hoH!T%Gu-|-%Ol#9NpF`cgb z5p7Vs+Zpn^H}f&y$8*;d-&o6SvLo3Dn@6U2Rq0_;QvMIMTwN`?#Zr8`1E1RCe^}s8 zkM?4^zwI3ObbQ38j~=~C$r!{Jfy?FUaP;bM?p`k6lECN6x9`wrK6zr$j{Kxg?45}H zvz3`>$w)e#x4;+5lPi^@U%MdpIOp*D68Q1a4~ZXL>$}l&1AG~LCw$&PCuYfC(t}@$ z738kcXTQbAY)(Gl#{%9Guctf9b&ZbAd~vDqKnouA(;l5vPBr96`9GudNwM}6{}ZL6 zEoky}>^F*hioF6+_v=^UAvW1*`pSjyt@6jZRQn8Z63c94XMbMm(~^IHA4*SnvBh|0 z272v5cCZ*Fjvee`OFS=&y{U<5&JK*z2pVC?MG|Ydf256X^cj9a*PAXezn#={?OJ}C&BNu9>}ixaK3%X zJ-QdO@Hw=z%(-Grt$$qd8+yu*Sj)=ztt&_WRdW9sz7n6=o&DePoQ22D@M!PW(BGIN z*38=7Fzrb3dwTkv?jpCuu~Z%gI{wkefGoa zfA|<)xf)M-73)~Ze~ZDN+0UNh4&~)xbqkyh*-riOF1~^O6S=h&kB{|}yR^#p@*5et zW#mTAj>2cxmbr?1L+B&dtMJ%cRNtLU&=?uYI5I~}JE%yVBZCsXCn zUdqm3|75nzrh}X{Au0cFU3~&QX0m^Jv;**Gtl93%w@vmeVCR&O1>cn~_s3_DBXUyQ z$~NpqInqJqqkgZTk9MYaHvP3##R2fox61#CSd4K!1wKa}A4Hck$mPencC7sY{lCkX za?u>_1)yW|(Yk`I?`LKcB^3q2xJrKa(px^f}ML z$MM`T#s>9a$`{Iz(-xEVjp)U*_dVD;JT8=L@Zv&vVXui?EH<>P;gG!*cAVqFmA<#q zD&JMiFUChs6??;tnJaW}Z&BtF=QZ$Oi~Zfk>grzCHn^OqucXT-aPO*hv_c--I4E1p z2Sdg0D0#7#K0Dx@%FRZ90sJOqHlh!_#=c2p&Na?^rL%koz8?7H%DoG(*gyM0r?o?M z0ss68eBOi`i>3?lqDu!nlQYRT@xPugwdGZKTbcUjl=%{U_&E6mK5Qz-pt91zGS4?q4MSbJ)+P^=sfQJ!=Ze69Q+7s)vM;l(|4P_{3tmG5WvjYb2{zb$rhz8qhkgy#sdZa_cG^&-d6nmUg6 zn`q%NMy)J&9$72jjg2**31`0)euH}{m^2Fc;|rDUu} zGXei@zPgP*@ZR9DiTyeJkLHJ0llOV}I`_Arp9VifAEK^ATU+1k*hcouTKP@Egbi8X zuQ~1e1m%{@p#DblAH{jU8x+4@niZj{Jeg=$`935^eylSUwgkS z=$(h@LkO}IMMGZ|KNS^<1?R}jq-2?{4e|QY#7~V;I|ph z=A&sO8{lzV|6hE5*m?CSf3%vT)n`bFsO?1xtv5?=61)Q4}0 z5g#|+;InD?U*9UVMHA9=Yey;q_AT&;Wu zwtb!YDbJFl*y?_IiZaXS88|)S`C*k0V$W1H?!zN?@mxBM-1%E=X7IZfzTNpwI*0s^ z_;w9>@MMSbPq?=L{g=c@3@Y&G2iqB&=E7I;hx{$B5kE0ahSth;HWQA1Kf$A)ZH}sy z|0f`xKQ`!*z+*kJn;&BhCVj1^f8F$IxcV96uvjmQZ}x@}zt3w#t1lnfj3Kg?HHg zAlVbu}x-$f}D$eFnMWAN^@MMDEZn`vUp}_^aCqJljo-$8WZ`FLypvKSk&Ene9)$2_uc13K}{YwY@gdd=Sa&|0~+ zn#^E7yt1EzZsizd8$Ofi(5c*6uH{(qzs8dA4*f&>vR2vlb<4HvS8@to8AER1k14az z^$z3Ht^6>OtV_uqgwJgBOVp*!^c#ZDC9U$CyD|4pj8&umDa0uad;(r%C4)N4HQIQF zn_Y|tAJ!umZsXS#t#VzneUbA*Wro3zH;!0>hQBND7&El-q;&aBuh^4PztH^x{xsZL zYd)=2zSEvx(DH5&Tke5ZFD<_*lb>EIzq3?qL30EAXl=(k${*$Wd~&z5^_hd| zzc>BQ!(#{f&(e7xx%^zKT#u~F(+qv&N__h<|0;UVrm}PJeu6UNUfYVBq!qk_jHk`u}+XzYvq2r_(n}J5&lKk*YFeb z3ca%99Otm{A=o4MJWxyOe2`ZBw$7Tp==z(EZ^Q9SKI@3}RJKLm89AGL9?xUo`seoB z+97!zW9iCW%8?QMaP@uITYUZ@`bXv2k;?4snoo=2@ag!!fZq{*pP-$Y&adyNTMO_B zeutyK3jI7Z@8PMQ^$vj@O74Byqs8)Ktb0AmHqjsA-pksH<@kN*-WqMn7}tDO40qjf ze=0(PN~^?>$1>{&&hZR(S}E4HHl7>~V`T~FujFu$|S@K4Cy6JOVLc-Rnq zO1lT2-CO0@qikPR;#Jhjd0D!f@^pwg(_~xBm116&?cz7QYctx_Z1}iVzQ}szeZyx@e+)wDeN!hjNmGAnfR?cB!T_sspE5AvfDN~t1E?W}oFui;pI(H}^>mO17 z(|=dj$ZCsAo$Q!H^}&nZX`AQ5#j&}C+~@JxfzQ8sMl?m)8E`h#wKvgTr2nKYjUhM3 zgS__DwQ_G{W<9E7D7!ugAEP_HRz7ntpH;kwUJjH#{R|uNi#7A)Zv5ZGpB~At^(pj9 ze?re1S>Nu*vw851@E@vU$FTijaX_Cv=o!g+XLYaqpCjs4CHVZ@c!2+6d=~pHiYxF5 zza;+u#wz$Wa_>^d2hctA@Vp@vx5D~iJOis7On-ie|5epk=DziS@?F#ar+own28}x3UC7#pBS%U8o`29c6 zPsBLdj;^SK-O67I|7Y(@6n@{wjow{M5P;k`rlv|oac_FH=ydq$#f>4?01OOB2p z_ai#GE;8-ZC;hbfU~(sG%hcPXTOYNQPd|jcg6>0ove;%n=XZ8Cr_<>vbmQd796sK? zR=z(SdmBQhYz%w}JCBwJawzr!=eN2yNJKhDN1S@6$m1dWi6(g-{-FBB z@4d6+=o{qFvwU85kMW~?sdlRa8Gh_4zkhK%yQ1BRx)<@`hw}eQf05rlQb$+P=W%sh zTNB?JkLNq-IDTJUEB6M~`MLaG&;$9CevNfC&z9*aF4=jVw)4=3yynJQIY&z(jxTkV zYvGj#@!!I(E1l&?nxn7EpKI8&6@C$a%Z=Cr67N-}r$pUl*BSiyB0j%@9}nN1J=^J7 zVeh@}?Lvp6!*B8#o*qJ<>)bn-&WE5MB!}?N_JW;>|4FX(!SO%7Qu@TVKGFl}a{)ek z!6$h4o&K4AGW}W{VuRq5qRUeoSY%rz>5IrlCE$AO2goxl1Gb zDObzycRvmPTYctd#AGQwjq&3dWHp2TrDq7$TDgBKxfT8!YgpRE_=aZv3utGPdmQ{R zW0?_f`_C%hz-OxKHapatDA>v!@0 z5dUGWy>}4%Ak&XrzlHzI(9!h*?U4FWr$g{;E~Lv-{~t^D0dGfH=Z}Brh#;5VVdxz$ zz2qJkkZyo03aB6+5LAkA6{JXU0trb-JEYJ8$qDHp<)pWJl1cA){i*Bjs=LQs_rI>| z>Roj$YuW$%JIC{S&1+^p^L@VkDf7%Tb8H`>OI5xvfZyl2@JhCR9zK9Sp4E>mhTmYW zufXSK$6r;4+0gstj*G7H zy@Ss6j<;$@#XR13%>JIO^DCYYz&#(2+)1>#S$|jg-a)p-xpycU_!q{=$)5IPMh?XP z%ST@y?<*JLldhwuoH034t~o5<|30Qteh;aGyjUaE&)%e$UZ>%+6@B2=IP~#P@+&dX z)i&`B!IGfB3=ko0fdbuWGi( zZoT{;@px)Ll68=!%}idQi#+X+^T|;7kHzE5O>-sq@pIh!B;(+3@#XXQkHljJxper8+gxx)Ewa`72^ZAm&ErZ0(gLGj&$gWtO4e(_9`J?OPt)h%pY!PoM&XDIrS=)X=bUsfY7@N6u4_hr#%bnBbC z=b_(OX@BE^@3TR2a0wrXdj`+>FnLW*4S|1v&tzrbeagjsSz^whdkT7amBM38UEY_G zT};4ZO!&Iee#b+fP}Ia_I6uG2UUB;~;J;Sa&k!$hsli+Oxi*W< z^X2(L&c(5gPj-xLE+2I(_d9v89{o0Q#4N^7$xu176|R2fo6*uESpuIR*WaeIyz1uj z;uhO-eD1dCbK;($Q{2LyVl`}`GC#A%Ir3KSp_Off6@#%t}qaLWw#UyqN!)H4`-$OrfYt7XrjmJYQ5_3P9zN4UT&un_W z#x8M+d;Vg0#r?THWIBE?p`U2`zWA!I`Mz|2pZ_01TgP8t&^N`oexy2y-v;76!}cS3 zkXPt;V$L$=7y9R*|7SKuJ9G;f_{F?gEo}Q2dDV;meRPcWn7>kr@|kFi(R%E{s(-v;y(v=ziTttxfT8=*EiCg@9THL zTguCo;;-uC8rU-Q1KF`ZowmahGM87%elfkqwKJSY&h5_5`HtmOda3Kz%3XMU4?L#f zFE?9f;d8GIp6rF++m$lMV@{{_lnwvt-gxj=kGc|VZO5y&kh>2)?2kGZvFxv@!}L!- zLWeKHcf$Y3riqTXG|RTBejN-R&(W%M$Ts2gQF2bfGjKW9H`lpbPY$rBYl45WNX{(5 zr=ZWr*dlj3=~KKd7gy0s9jG6O_Y2yx&Gy=yoZXF;_;)C8y?)N#Xr~f&wH0m3!Onl{ z-e|48wmo9ew~*fsaej_^t!_oRkYAlgF5BbVvoQ`&zB!=m+iSDPJG06E zM|t%v-jhn?LDq!NQvQCp9LN`ub0hrw55+n)?SSv=)Yj8UpbvG3tZd9PcS3(>~r^VO$Q=zBZ;_CSBC z{Vw=RVt&5!OX&C0USkCH6Mb?5{7QC)zt+P~my_yE@`CZm_4N6(dR2#?A=l`W>}h=6 zO}<=;^?lVd*(bk}-T2^h=!d~CfvY3^@+i5#*SAOQkAeS__WJC0z7&(;;uGV%~No!Y%`{V*IsA;VNG?5KB41TY(Eb^*!kJb z^8X0pdw+S4w(Wd=_$vMxUSsS(Vn4CVJ!GX^8(6=ynRJiC|5RI(Z!f5nYY5``SI?U; z<>DQMRQ$37$$b$vk>A4R1not(A8H$i!X~@^r2Rpa^4s^Z-mzAr=TtI=pg&Q}F0Pbo zB>LoV^zkuQSCCm1ABj?~QIBVPAb5?d9@lZ`Z}9V_CC3C;S5bSHQ(S<~Hgpn&tO=I)>nJC0%di z)Ai_A@vXuooJ=Tv%D@SjdLJmzis?Bl*7y^1aiY|aa^JMizEhY!3%{~hDWmOSkw zzgURR)ofd9Be!o0Kj0N@QJT?xqcZ4ff77*CsXRQ|$WFXdHdXm4YqIMa^gHN7_dIZQ zyjsxvTC}@d=Ys}3pF{Qrbnw4XZ%gim+iOX4bOX(o7A=|<}z}8tJkAnLf1Fieuz1tgdWKaXg^S9bhGqZ z^$K!AKX`QteTTIDoF1=S=qKiV6X4JCm$;<6(+8fzJBPdekn`|``?f6ffw!I}qr2%p zrO2#l54Pfc6CR(n4ftE0nD0p0*@8z7C8vp@yo&qhh)3K%=SRDCAfNGFd{d!^{dIU| ztPsy4B5z~dA9FP^j)}R-e1kSMeEN<)k6&W#QgS;C{Bc(Di`_jyy<{26pJZ3C70y*B4k z?WT61`ZaX?o59oLn&tn;RVUJOFh9PF7M@j_?hEOyovDwhlzl@H^!K9snKoyf>+hhi z;eRXo=jiuQdXJV1Q}Gv%H1OBZpU8ImY%c5-7`!DHvdhqkafjTfK5K(l-$zeQ$Dn5W z-s}Gu?x$CZ=Isj`7*a=!>!50bMD)7#DOJ#qDRe0JF%+s5;#%kSZT9X&r(j*TV%UCnarRhP3p=riN;Jov-&50pQT z9Q>2-Y470aPUq@=dNMocQyAZ8@NAp%%keyd9QwyQKq#x3 z`UL*bw$nS;cg0*l@= zzW_gY^xp~o@hy-T6X)NNukhBX&GLUkVoogXS@PeIdo%hy>Ax9$v8sG;w!WY8@K{&Z zy#d~5UFKfSk7@cp0_DC%PSn9S)q(HmW6wqpkMD65;jcfr4|_wb1~l8>-)MOzsO^pS zz~gPfug}lppgc@C)5v zJkzxHh#wAysVBusGS&NXP5*VyFI1<-;`9IH!ME8u9G{P)zeAfl9R728!}k6$_`qYV zSIg}a{8GMpgFe^FlOeFL%Afu5WK;5Pv+SGu@os&VUyj77MJg1;QjUd3}8-EGnC^lgSeYHajc zrCcY_w;gUg(?kEnm^#*F)OOKj2mBIzR>8lg4vk~qhxAvUs~4kR1m6wao9qqxz%LS) zS;`#I6c75qioh@AGyad~+pWp?)1^D`3($yH`f+t>YNh;sQQY5UovTJd$)#khVN+}wSl?XDGgeik;Lj8h#C=6mtZ*E_yiKFsEyQ}EZP zCvqe1r|YMXPuENi=ljrM1G-J{ePNTGpXB%qHnP2Q3t4}!O^h5((cdJux6)^; z=y?L4f5~;e?b(L@I{7lHS>8_-gXp{y-|cXHapP6`99KzF^|Ar)54_VDn9tK=E9DxD z9A4c4KSjQOty!*%%4eV#(*``=i%P#pu6os@K6Q{AeMNkOCH*vCM4es9COI7R$!>gc z9iB(5@N9C;8U17XAnQz`pce*iPiU_ z&v3l2pr`!r4qrd1&DqobSv*eWw}p7_qUXEXv^^^2|D|LHkUO8x;MrPy#3%ZbxQFgz zQ!VI!f=^N@zcG`qVf)PR6~Cd4wMjAGn2*sPOhvy9J>O@4g3p6rE!XQ2Q!-9+&HPgN zKDV{o>AUn?N56~k`5!tBQje0$%k{DK)#$HvE#`>d)Bl;bYK_B(ZP^{Re)#9{%GA{{ z_{`(e`{DmZ?h@M=`8NXJX?XOIs}CyvXj`MqtLP`P=Ox$w-TCqDa(#o?^*R|tl=(Mf zl&h3G!0|WX%G5?WHb>O@ymBWik8h0ET4R-ud)MdDUxDUJuzT#^hHHB=dem=I2G+X9 zeiHm2y2@`k$DCC5N%(2@bj|;cKKxjJ+VKr!d{pdbJN~XYC^j|*yEXuC^Ap|TmiNJz z!A3dm#QS~vEpog|+qMSX8~k#r{q5$wmc!k%Ha4>HJu;7idnVVfKghPkd>+3{M=$4- z-Q}k8*+%qY9nUn1-Qh>76Ji!?ZK@xlCoB0Sorm_eb2R)5kNKT=HzG6V(b|k=HUI3A zqjEe$llO`Ke}4C zH`~w4=A&7G2VU`iOzFeTax7cS5%UFNw*$Vf{Lb;GM?4d3u(gM6agWhILfwHU)7bS6 z`oIq!U>!iSoIfq<;x!5uxMzRe^=7%YFy7s-=JW}<-!z7rO>P{!Pil#69~hAmw`MS&siz40qul z`SU!v7x$L`os;~@vwnDO9zMo5nRYSW$<98)_dEDg*?1;fXkWY4pO$vHXPZqP$9k6P zVQ7ZQtF&3ZAJLL~$$fOcRQZ?q?rgN{Tzd=sU1D@6`bW`R=K5+Ip5?vflkmIw?%Jkr zyEUz?Kz}1!hl)|O6}O7XJlkN$+J(OHP4Bf-l;MNv_(c7@T-?PjI|82vn&lX?{#EoB z(DMWQwR6?SLZGwv=jJ2_JrPsIIo zvQ>Q-t7IAe>-lRB`8oq`4MWUf#{VLg^9br^HV6JuSVOz~#(?raT0QV7-#beppN1*( zsya0bpX>RtL5}(#|DlpR27iyQK8FXt=F#q3BNXpD_VIc8IQnI_!}-A2B{@eO9L&Gr zUv`Q^+&6~J@&B&rZRGMxVpVIy&@ADj_ zUdYQ9zb3Qbm)m>3WdKyJm+jn!PlMc>;6FltfY`5<%j#&i_Oc~U%XiL4Yun{eCT=x# zy7&_O96tJ6(DO~H)22$f?ke8#s4aqrkNF{wwoSgo^TK4cc2BJOw<@FG z=#ek+zYhu9d&InsS8b`X+R%JAZQ2%fcR5-<>eS9fUgZ0V72S%3a)OM$=@s)@@B`JG z5ApfHvAEaXg-86=tL)oyU>bU|^Rf7BBKJ6a(04}e%B#lEN@+`c=cCSt;j4zWDxNu3 z*`A*zx7Z&4QI*Qum@{s%FS($%eEu41va9kuy`fUZGrx>nv93P_U#$GubmrH1k3M5# zyo1=yFELI{#VxtMxBZ=~fsW^q`|sLQc>W1FJB81+o%Px9dDNDE+;Xg9|4?VxoTmFAu{C{n( z{KR3Lzw!~6m-uZe+&ijq zk5@gE&HS3{`=afR^&075{yd-WBJR-^oT4qbS-F}}eJ(T75=$AUiyH+P>0v@L*dupZq zzZZPe;E(JrJg!4O0Dd+4nfNG^Pn0L2LwCh9V{~+hx#C!pSIzNxP;Ab0E+_hjDl>)u z_$8N5$??wRa_1oXY2=bq8-o5|`tfD<6nrNC(l?#~)c1y|1I0tGe?WhE)AvTDT(=$l za_cVmeEGwFjY;qyYqug7Zd7ieI25kWsFd?kb-B}$OW6`}4W4r=Vul`GoDN^EUTjCR z2%quB5BOA%z-tv;KK87D%Z13Df?j!3KlE+6EZ378=)4L3pL|x(6JFN;L-AjS_cr(j z{=p~Rgnpy8h#!g>Xt&@=Zn71gSzE9a{tA3M9q{bb-WdocJ@e6A1K-uouV@RBZfgr> z!2d^n^off$qemT%`>PK1JGl-1UAVHfz>hR`9tYPK=6@%Dr->0hk#pU0G+8SD{(-J< zi7&Y^cB((emJ_>@;z#DwN8&@zOut*bAN^P0|BU_xeha%6bd~Q5#ebD0|H|GkD7zm0 zmag)>+?a36jIFYaZ`a`e&*%^0pC7^hgAY#-$CdEkJLk*nuX6hr_J5}968W%?YvLMh zN4yswF)QyK4srff^3qCq4;Rl88#A>pC(!Aa+V@WQ|L~n}71!$coCWu*X7+!CTjSOD zmg7r<-q`YXI+K|U)KAs$xd#3?o3wq||FL(jvD8rbz|e<}z6#&1S$=;weG%>5&i|8b z&y%~mcph#4FuC{JAEYlBEa&&+g8{HR(LaX%Ty6M>if_?0%lWv1ZrRi5Z?wHm&OXhu zO^)ZH9l2|G#r#(C5iuN2m%}Q)IfCvvZS{Nj?$@p>iLqA0_0D0osp$4^`j1&WKNNFy ztbZ(BW;s6qpSk$CKj?$C^ds3k+ks8rtcBmNJqda0Q{O`L9q8qLzMEW_h;BZ;&_!LY zZbY}I=P{G@MN7#L)7Bx)a?U?_LBCCp)@t@DA7kpqPV^_JPhys>qkG}{;d1Rm{I#Po zpH`D!JsZ#;Np9fkee{ie^0D8w)=~KEQeGR-L%$Ar9d)XD4Eo4reyz!&PO=)?&>xF_ zGaCDRc^glZscNJ8$xkj+w^#8OEb2()X2Fi?jjs2)jz^AHb|U|fm)whfEq&+`_7o>I z%m3YKJwYy;Vhj586m2Qr=7Y#*Pli_u|NKO=yxCkM zyLtwFzO4L2dv@j#7uSn3(cSF$eR7oVkqZs{lC$Aw!e`SfT}mN{XZ01IyBgj zUjQHPnb-z?gU56CDu31aH|`tzHn;Ii9qyaU7Zz(*XeWx z>e&E#k0o~tIr6BGj~O53UCr_xoz9K+{2t?u zsNdBJKC|&J#D(AMq4VeASMlRwy!bQnE@8s*v4nrgqSQr=_M51`kz^!O03cj1fCvnAFO zWgkZiua0-#fV-amK-+g6nef&Ka_8YQo!w#(V}uwV_q|Nt+4P%F|FPPH>+#=;=IhR9 zkhc_{&*Q_6g8s=D@DaaqOmw(d!joQdZ-Gx@*E;8u#q37B`6m_sj(6y@37+_F8oZmW znR?kbp;C^m%kRG5r2b5%)4q6#YvfD5w>~3qbtw6%oEupw* z_ig!C=*g~hKQiG(OK$x80Qc6Fq`~&q(KdKyj1}KB%|1o0_-AJ;Gf=U}(GHVAf5rCi&SMw}leBXIq6dWM)B zP3~~CawvzVdsmVI@AN=?UJ!>@#2TKT4!@&PeuJj6nlLwu6yn#2oXAs&~@oLJ0L*a5RgSSRk%4a^=boBqNuYVsecs5ZUyot|9 zHX8#pX14L38T6hUvdMie`f0kLugc?wYsn?}>|xxg?gZWu?fE?T9`MY%^`&jRTRqIx ziDZh{{J?$w$~K;E7QYqne)9qk(6!mdd$xz}aQDdZt+zysgMYRw;td~V4DxUAjcq*o zwVqAzU%Ln7)4E&`#SeJj#@Zqjz+y86YcsyTDhvN5PJ6_$(+I*7l$Z2(^8(xTG)bp?{#%BF| zp1~vTeea{8XFVZry0Z9JgI-Lc@5=WV)9@=igU4J%$9mm1-RoFvGJQ=U7M=W14_ppLTNQ0`N=~1)pbF2Ar3-oW>m6?;YlG|Xn2YZh z_gIJ4$BzEB>fG5x4F7khv%P0&xW|)=!Dl-&>tRaOJco+0ZV&r%Mw`N zuN$wwO4o0q--f?B8SOy4(~vxm{$+Z|ne+pC!i$CCs$Rr90Ff8{)6oY0JMBu;^%3;! zwXef&cI`OjuEM8AKQW5?vS_cOFDcHblx=j+0(z>$N#OUQI~d(tj%TuOwDX&6&!IP_ z?o+R-lh6-k2Rz0a`8GItwec12W}(xrW^cmZk>Ats6}#+7<=}nqJHN}gaTb~v`RzgH z@XmSm|78r#&g`K|QoU388ysu9;@yU5t75F&5}y>_--KIJ5No4~3*eW~(Y3PAJj?iP zsq%-@^HlWkWE;A{a$h`(-)f@{Lw}u|*%|!h<|_27wFP^U36Fj%H=me?9Bp0PbJRbM z9-i*Y4tbD&RNu8hJ%D%Y!tZ#r>us}q*HXQyUrV3u=q{%32KXj*^?CI0@?MX>i%sy) ziuJ{0U4u`-Pxslj(8GOf%n2p%xIaz5Zod`%PvD2LJ;T4j?=LGyPO-mh`x!r74WB_D zxsdL_>kN3z6~L2;u!wcoP4D~y?c7z(at%PI*f!vGIS}*aEjf}6f=$Jbed#xxPv(or zorloPQ6AnY7i0dt-eoVAiMFv0PhLm&Q+x)%Psc|rYr%&OF^`|Y)4kcWnqT2P)6gAS zDeGq8o;Kr)7JcHmcI%W%`JJL-kg?N7bnxz|Lzh(C+S>oZ{V<`lrw704r}4jx{L|$i+D_N{@yowhN$Qv4D`%@AGkEcJ`t?egTd^K9d=qmCF?UtI zUnb|`UMzAb$D`A}V`#rDNZd;hTN^rfuHAi zHu%odF$cwp*kDS$)Okg zEc9FKCn`Uk4ioTOOOG?{ci=yczVGwH2y&IryPn7r_6bkGY7>AE}h@CnbkD?tpu~ z6h5z?N@hXtLzMj|^?s7NNw+-1qlxZd$8VwCWjlzCpR2UzQaaF|g8omoy&ONumZj(q zbUfEPc!$#YH^#nfiTmdK0Jw2Ueir%^1@!PfIcbq}IX9Cn73+Vb&kpoq&+mZiBH}bP~B<4Ztk+XNWwtH_mUmEuT-EuhA zG-j8hzgeuB>>W-|cBXg1j&t2OH(yDgXSEfJ@TtOgQ%2qu?%&dT_+c(RA3@7U`A&Qm z!Mo@)1MN3#qxs;M__$v$jzK?(A6|l=sy^=9EOR)%DboK&rTq=)OV!IGn`M7qEMW6! z{D+}8ercW2w8nvqPvI5yXB4^D@cZRWIR<|X&9n0Ib@cDj`zG}E@s4QqA$F0GiAOxw zEzGyadeY>RFmKiF?+!es z+ddujkr6i+AB$OO=}nn{N9$J?R(U|E@>@V zzxKPe7@r032jNlvB6%by>Z{PZ7T=Z0BS(g_FZw5aaP);S)-LOgdzwDE)wzjHtKr?~ z)uH6y`B&X)ZAHHZF1MntM7z};IjWA-*5R+siT)1HjvJo22URrYMB5B@PGNKWTB@>);IU3e93j79PkpRnf@$MmX^ zSyLwZrHsG2N1}t*wJrVd=8=EELD&*!CGa;KlKY&t-7+ulz4UHWuPu>>+R29PfI@GwDw8Xz*+DAwSNCKc!DQQ@;f-^JpBu z%!YfGU(UC{Q+b$oqT#D}78CO`eGwyZNFLP3OopFgz1P8X7Qe{Tc>k&THr#ljbs-ty8h}oc+203b0H?%XO;B$R*poh=IwKi9LzSC8Hch~~*Bz=|cY)@y4 z17Fp@g??W+eld?$6X)uQ@UOu=lMlV(nP6)Qxm|qvMYujL<{LZ1#z|=JBIDDJFSmX` zeTsM|@}mAk#dzCs%~(Lasl7wb3F`YR=r4h9v{rOIyT+3JI$AlCo(lg1dU+9ZJ{_ab zzU|tf@FU%;L`-t@!(i%ux+nZXe%jUaZjmy0*FK=D`?6RsQQoh84$VvK(xyaT5&uWu zjlQ^^AD)7(;=3vELFQw`q}qx85xfh0UWAXqhd%ky%6y?xj!Bct;MUv~>(J66yR*_> z$Mh39VjLIujfs9TzlA>PKzyS%zIR_N!si3^>}q+s3;qlJ+Gfw%_%iSZtLR@wGZTJuZ#kb@OoD#{JzIL_JANMh0KS-p<^{G-l$T4`!%xXD&$VWw zdATd;n}PpI+kAMpwQ*7QRrC$?YtV1O=WK1rA~eJK1Z~fHJpY-$_1_)K9gk|qv@hcQ zdwtms{is-GuWKhZqu&Sh(s~n>?5$*Ay_}-IB`;sxWqwCFI=3E0JBMBH zY&pB;D%Xqu)9h6T%euFkpQn(^R~qi}TYUB4uWod{K~KJI4Ti5(hCVTuQ5}v4UiIP4 z_Pss*lg<43jJQlkGn#LS9>=wFyQ5!IW zt=nwN@cbwEo@QRpUmL`x`hJzN|B89;df?ji{2%y!e`P)-SMK-0%l|8qju*4WPZTju=^48yYbQCeDO9Oa;lr3 z(?Rg?#S!$OOAi@+o9#!k(>+A%NdEkm@^Yjj^p!htUlw(x{s6i1HO4^E9vAM*ve5TB z^c&I3lX!NR{7OuZ6|(yv(&x+cJH_=W>IUB=bDZy@x0qH>tyn*-PVh~22HW8MgOojuoWszp$K!qa zYpWW=;HRU%1PxmollW+xIE_Jb2KqbsdX?C`<9t)49qTjEf1OTC#pMmM*TE;ceiquH z%7}6Q>twI4xVML&jo!DZu96ZFLhNh1mo%7fu2kPgdokO3u@g7X4 zY0C3Q{XE#>O1ZW*dx`9c=(H*M`Eat*;e7QJdtYbQ3V0`e>&eLRd8BDfTPbs+dVz9$ zUOa~83*z*YSOl*qMl8bLknDk$A7Kl--4ga$Ft=79DMN2Mi>hXgWsu7k%K+c@wu*2zBgN!XZb6} zv1ED>YaIgm{p9l7_QpINYw%ZZs@JpWY-Ok5vs7QR0Y09t@OV~Nn}uFqpYd5Tn%o;I zNh@q$;QGJCSPM3k+#C7lkr_Mz#Jc<4$@l`KdYv`#h zjoc|7w82|5=((O=C%Go(#huFBj6WM=%olYqVq3kzHF&bpeyhD4t8XInQgpXCFX#|C z&GvL1Jx_BDUPIHr19rRP)o2g5eL`70dv=olS!I{vzn3!bXd^Rt{-AAM)3^5IB0T!J zDn6~p*)<*QX8ppzO1Yl5*oMb*j@cOhty2WfS3O%Q<@5ZyHmIC4zZD<8%{SV=Du-Xi zU!PuucdqA$0-q6deH#8Vbp&3}Iq#+W7&?C*ezCl}jUMtSUygpfRCCjeTF=O=Mn#D_I14eMICrTd(Br3an9eXl;6%tqVByT zkKrxxtWDs9S;~A)9c!vn@akRsI*N{D#Q(0vbEYEj%hbWa=oi52`rZT&cyfgJe1jhr z;qy_n2f?~+|4D}jlw0a}klZ?n9q?=h9rz>Wf#UgQ^};ruXZ&|L+SRW85Bh7{crtKx zc~2MfW<~VhpL9L=FEbX~-=42iJX+Pi;a}-0zm?y)9)I!6pZFX9m2~UHSImlsY%jL) zct{q(al`H*XR*Y@?p?)7cUs>K)#JyLa zxP{;H=iyt~Am@si{OVfxIo>Td^{>{TT&q>YlI~p~)5`4?K4tB-d zY!#0*Y^PUpi+$KVw^^>UY|L{E&u=EjanB<3c*p%yKR&IS*v-$$3i_;7RvqhxXYhFc zslS&W)Zq>>@1{?*ll2i|v94LJ2hE1!!KVp4dx2dWoBq3oyl3!YN6abaUts$db!iDY z$NluJU+?%D+jer*`P_NLBe73UbiSkMeNDX2a$Mkjoofl2oz3!_q16N1IT8I!yvvir z>od?8v(;9@)TR6}{R%!Yca_c6$3?w_Cu_AU+754w+8YUYm-x4h1bvE`mMfE=%1Cpd>Z|D{jl&E zKMYrPnLZJ}==YOL@m?ZcgO%;?^{qbpF8G~tVLpF;%lV3Es=%x;#? zX0uPwZ#aJpgkQ?vi{UTf%O`z!R_PvN-ndsyAGJAdyazuQez81VhxgsepXq#t>&Mw& z1n0}_T6D+r3;Xi@;TMu6CdE0ngB>q}|6W^k0l5*|!|~dO&hjzFLPaN8a1WTuwQ&uy zibZ04e$*TI|I@x38}@I4kAa_u=Qg;Q#r$*}=bweswF6E5WqzNhtyDHYSU#OY&$a0H z)pk#Shpe}Z@y>>?aGbhteC}^}jECfD)_^}?F7YP(Lx&;mPuN~#d-)9RaQ)U+#~-1? z8T6cp=2>m`cDY7}c-E1g&evnu6mt#cRJ+56#!ty*AN<-^Z4dE9JN-KN`N#I57NdJX=4Bo@YDezx)t7abvW5@SM^2f;;r!5{vhbvtZ)A%MtU+(%`*Pp}t7QBvi zp7^$`+>d$T?0(p1#q}8U2dcAU;Xk7Hjp*4^4ZTOZUwg;?x9HEs|7miMa{gU@f1ljF z@Hhx9zqF1}{-fHc+4#YtFOJ-;ACCSUvil8(y$YX;@cE^FOq?Poi%BNWed}8E!rTp$d zjQ8Wdto1SFXTY}E--qu)V_A7qJ~M96afj_;=jM0vCG^=J-xcV0!k@&S9=ZH$?MF|# zL|@Q)p;^|sRNcrwLheTTz_VA--xNCIvoHO(DuaLjP;~b@Uf=X=U;TnL=#!76&vWF+ z#dx+8GP}2+ui-xo-4=59AQOEz8J*#;apUO-RAGy{yo<573<$ZeeLFIe;L)bOawzTP)8y&X7 zf3B^00_`hw<@fM+z8U`S#&LJB?Mc2{MJ_zu2>&Tu8<$pXP4vNM9eaPG-X0?6pKvZ7 z;j8Kzc-+7Kt1{Zx99_1V-K)hxJ&&~^d5V4my7g>d4*vn{0=^LcPH}4RL%Nv%zK74D za!>!)pkH+vy`s*42i_OYLyY&>lP+kt@7ag=Jf*%3QV zzpc0I%VXX&`mJIWKg;8IhS7sQ#xYs$d^vkY*l#jEgy-j1Jd1XHiMIVs<$lxKUPq_i zCJ#DquyZPT9cby3|BA2fq>r|}e=d7YYnIOfi>2gUkAAj%5bt^yoA@FAvpKy~xf$+Z z)u;L=(eqpKe*?<@LQJOWGuGqtkl1d4#~Sv-&`)Ns^XhYa%>VH{^lUHtceVNWcD#ya zqwPz4ZhVlv#SiprjTS@vqAf4nmlfmb50AB*jRGFD)7WU!OSf3nEv^(`@ z?N@kS_9B0L;9M^EHfC> z7k|a)MtqKS3{UwkY>zqD{OYc94N&A@U+BnJQK$0D;EVW)+#WHg@@?5Co=?U-a4~Pu zw;u8{^ryoo@t^XYLtMkJyw^Da{umkoB3L-il^b|t6zm5h<=Wo z7L)p8?VOAIrFd?uas-%lUvy! zKJi>P{+FY;3%@#<*P>mH&ztZM;ZIh|cLH1TI}yw7_ux&j99l_wYIw_?)=aYBhx-Oa z$9Vk3J56Del$izh95$a^Df^uKQ2LK|Z7$r}>S8vz596~h-mkG^A>7a9(P(pHo#5-&5!e4J_TOIJ9=qXdAm9Er#&1{ombhy*^Mv@ubN{$kEx$RK`fv3e z>6?ZN-|Y?6Z= z5w8_|vc>rzW%i-tL^-wsZvA!H*1V?7A$UwzZVCLm=zq;G<6tMa#wS?=-h#i&N6N>& zU@CXYc^c!g`~>4pKFThYr>n`Cf!?INVqF(+l-Y>}I5xu_O z2_Mf7Yw;=QxEE}U@yEOP)4N077eml%C-SlUCSGw5kVZW@3_hH1MrZEX@v&|Zl zSaXuRP5;Nxt5*%aOP0vv549`rDsx-2ocHbI>*SMY`(cyuIiLRQ$+z34!uD;J_p9{- z;m6~%gFZjT27xN|RZsg{* zc;1A+wlv;5%h(z7X2m_Q&)~Bd{%`zxwmuAAeF~2+*_Pq)VpsXi{lHs8*zyf!w4?b3 zxb;w3;Cz)2BD)FSgS=m8&&chVD5k?)EBNP>N|MXF3?6H>>Z9@Bfd1p~ckI~}W42f$ zmuzuZW*(aW1+3>tY`-wt0#?!U`J`H{Yb zM%`^}QBHkGe$HO`(Lb5oCCY6O_swMd7r($eXYz|W6Lmb>YKz?3%HDa+a!pKQh3jm~ ze}kT#G3HA?K)(<_vh&}eFZg98zBA#o>BHti-(90$hHrf;etg^tT)!LZN2}`{Pp0pi zf5ZQe|L36N--dRkK9YR&)!mwO$CqyTo5~HskKAMr=i@7-4$+PkkDw1d_Y}WjISx#}ihg9+PyYkp^VA`-iU-i2sJ&Z;$H(ycWLNp^mQwy1 z+L%S?JIEPf9*5tmx8kF%D#vmM!AI-E_&L88etlQ@|BKln@K4D(`CYxNk`(XZIgJg6 zqMOgx^Ibm&{oi+$_X6qR^!~LrPTcD$efG7zjrItBIGXRoDLJgte$G9Po<};rQW<#_ zYa!x!VfQG#n@%-3H> zd%QTQui01dTLd3dDd)_K6WY48KBeqC_`JimPdh#l?Hv27?FaF}RQg|!&q??%hMnme zzjn?Qqf6*>GI`>V9)iEIci&?8sq8w1?uBcA;G-WJkFO&4Fm$IWBUYJqBG%(%MWuY+ zTRmMFIasb4^=+5_jg|Ji?HTChLVh=TbA0u!aQ9^`dPI)I{~twL-noPR+UWFb^xNPo zR+aN!jh*-p(w3Z~K5d4NHKwVc+Xe5^2Ar!tsVn)JU7lB|<3azQ^58u5z3{>Cnl@p3 z(35{Ye=cQL^r5q&uY?!gJ?Z41dFcN|j%rI2`p19t#<;jTmEH0n`3HHlw&I%vUFDkA zZZWSPPj78P$4dC4WL=EUNchF<+*7$luD=E6>(-05n=8K4!B2D0uZCZW_I`5jb1pYx zY*qXiuKv|OPPY6g@3G%>{r|{?r()B18UG7i9|i9~bGhR^*fj!PLF0NW#p?<@^#R@5 zg6t;8k5!WNf=cE8T9_u8Lo^1|K1h-eT7^&2pVoYa9OK@sT5a z@3H+_aT)CT4)yRY_}hHK#uhxmFY>JKU1jxK`7HYIeZe=0JZsd^?@f<+^tn!dI1?ZA z)oHMk)DyPX;f)6U@$a7rXMf7}OxPhE<+lJ2#x)4w*KEj{wM>HG$= z*cxM^`a-;4QNBPA&$hbmIQr^j47v@@Z`7{K?P4__Eyq(^)xSwuc+{yH9a<}!p4lj) zd@2X3K~LAL7Y{l$=$6CddR2UT){v#GNR*E;O($KdQ{ja+M_bVk&u+F~!)Fu8U9YaK zr3atHoNZ^=JFZf`*InP(EPd62r(~uh@X>x$x7yHWw~#-hl61VR%;u2Uv=$H_coP2q z8XdL-4|wPe@2uhTdB=F9yVy+s0p6NJZo~p!ephdZ_KhyF4!wIW-2(Uhaf@He?~&EyZj2*x zc=R<#Rp|5V}+DtjpTO*W;jh2PP~8km@8=vfWl)A6%yJR8+4|NkT5 zo9x@_mv~gsSKqdgS6xQV1mmQ8+J5MshX2DhIT-hE`M|b6JLaQ%kuMweJ$zDWVab%f%?32}Im`BG2lsPsQik=zk|p z@Kjt|Gto|Vyl17H8_wX>9WXl8CZbQt)IX>2$l-hm{7v)&;A7;#En+0z{d`azN5-A- z&zKK@_lZ+Oor^l1J&ylDUFCc6F`nvEw`#8`_cHnk=BUs|f1Pbc|0@2Y;nge3HDDc4 zXUWJ0k^dHan11kPahqeKQ|np#_u(JapTZ0FRCg-#1UYlab?+4QJsXcsy=q0A7oj&+ ztHWdNDuL&pv&r$o_$h;@Pbm|8y7AMd<=?^QM)=)iYUB}Y}$(UN^-XH z{eEcS)g#gKMRq=YwpYsU61N-=XJ`sZ*I(h>l149uRntx z9{1l>zROle-Qb6p(SIENdwrIeXT~wn#`g?I53e6vDc3H>yJnq>E9IE5el7l8mGZy6 zs>sq5szo+{3ZDA^n9B>Q_#YDhNHQ|v9-6!82ss+ zXXq!uEAT)2hQ}*-egM0gep4#tyX*08OWIQ@-;vJXRXQXGp&x2ocQ<-D9M9&mC6#i% zFaB#ZQ|DSM(7^jvqc@gIPScj1gikLXkF8{C+z_-ePCESfLJn#J(oS<#2JCTxwR@YgG@k z-;O?V8{W5+|Mw#!#cy|Z!m~@&>v`y4t+U1OczVP0OI({OMwh~Ep+}wE`o&`W7VHzY zYw-INTj^O`)btG@ysOF#w112|btHi&FDm~O-eQuz4Bw2$g=C|@T8nVxa?*@O5jBnZI@Yia*&alDeGC!V%RzK{Ely3EM(It{t@$J0sL%xbFcYwK6+i54g5t$ewOV5 z^{N*}r|9F;>98~Ds*ma~@OY%S%%oZ_jOrhs4 z_;@@{!;kM$Inh1cHS`4^Wv9aK;FBHnnWbKeNj4OA3O_sn-^r%A{JK>fbG%N54{5hA z*#ffpxb=0sPIP^&Yv?m=WM@H_2c4&QO>zzYj-{3IUA?l8J3;$7%k^z^319G8dIQ<= zrGFv*^zU3%Dc=LF%Bi>?jB#7v20ZvEpNc=5ievC5tF^XbO$-@)kQ~lGM~lyH~s@6?oX=p>Q|?dV_<=g&`!87YwgX)5970ErM=c{ zleX!f(DP|BknDfd*Rv~gPnITV_JEz^+Fa-S5bH2n9j@wM=yNSwVV{Jdh_hx#XD^KNCCP%BTnW0Javi@! zAKjYH@59KQOdh4mbn&sNU`jda7f1m$n({ER^{eJ{_ zmHgGqD*yJQkLSCQo6(lWyieTobp)SN@jlHK`oD?)P_}O;_h{$)*}~rO^7HFtZN>j% zupb#~g@5FJdME#ELcfRe_q)omW4zatUFv)txxehP{#9LMXE7ZA!|C5_`i4W7HTvE= zh6)VxNtM(=of4pC+Nd z2d2(<4}l+ROmmyOP+#&-!&7CSfm?-s?pe6)-CedupT z|3mHUh-Mk%09W zr}y#v6}w~ZM4Oz-qvT|HGFMskAl8EQELZLt__6#a_mi>kZ8q^~T>w8!{q4o)I@nL> zv!1LI;U9xLj`!oUKk}cvP7i`>*YYiJ>!S00wFUCJs=f8#tGo}7_ma~?@sQWWKk@gk zSC=^$>#pl3kRu{r@4{Md9dcvcSauEm z@-#mUy_kod_5J18KyAS$^lSAobK&etPOg;ido~Kk-Vu*A4&jI5ayflJn^wcG)K}H? zdEvL)DoODueoGviw@hBPW|p6;55T{H|2%T_r}4e_9&t&Zu9Pv4wMF?L_&{Yw$c@49 z!LW%o{k4xrc#jaDQR>)m*R_A~-fj7ft;mxJ^c*GEUPb>OaP=eZ4PvgWdn{d^rqf>b zy`IO*!+5S-m(%gyLG^I>VDCErMGnnX|KD+b9UD)x_Z{(KrTka~S2wC>l65KEv-|uj zVyw*#`-%hbx&dyjXFS_(8RPWKaBhs3++}|`-`${|tgDpoktMUWUk}2^dmeaImv?s1 z^G$sFull6@PW02^(SOLzWGDaT_PeW7+JP7g#F#7QSZYJG^8-k7Dhs*e=CKzGvgL;i2z;!pZDe z;F{d;&?fb3W8<5(eRPaIH&ur^X5vY&d=G6ToBF1NkL0Ivt68B|j zw!#nQd-O49o5Abi9P*Q+y2^etqhH{CbgPjcT9O7AfykDQpbe+Evc9r`F;5Wmc zAZG(Vtd;}fpFb>T&m`+Wa^y+O5q7Rs?rUA`|Fpi!dKYnO&0}N4@mc==5RdE3`^m#P zTT*>o{w;b8haaS!UfC?)?di~_#JgbSw-chjk{{8BG$OBt!u$N!#Y}Y=T|=%WA2iGV z*Nk<{G0sXBvg=u~n~sk@G5!}L#rm$f!{A>Jih5a()6#nj?)s&;ojn9%Z(Q6NC;?~2r+0*p+g*fm@ z^ef3G`YfgYW9SZceP8b>3i_U^(}udujm+cJvkI_zAC?!@K)uH_wMEyL%zN_qd8tYw?r&ab5#Kj*Jh z%;mebTcymMaXgH9oJs;e0>Ws;-@c zZUdjllj^&8eAM-IU{Bkmv zy$8ak+D7)4|K}a;L9K!BLE52t%3Rr-B>Y|#i@qhw9qf3S>r36|p5eMW(O7}cA$T<5 zjVpW)(D4$n?4{44u5EztRL`$M{}jFSiP5)rY;~?(FK%}I&)UoN&W~`sgPicqryXkp z`!?ctWTm_}%+~S!7vMiJ=HG08G<|lu{wpzfSotrr8Q<&}*mknR?k8>Ym2;jSTWNpu z;}qriul(Kwd384SlV86QjrdhN(7{{BYya3$zr9j^XFKK)<9;el^+WoCdR^Jg80ekYSF&dF&0ew)ri@uPo1zn<67xz>13 znX%}%(C1XwVop3g6VA4L zk-ckGwnX2borOkS>JZa>0ov7QSGvybSVP{AChogB*P&IWdJg)kw!nE$@Ssb_LioAx z8OA7L*NIm@o8q21KM($hF*I9>Rj%<(eJ1>T+l{^D+)pZxBBnk375A`(v1JdsZhH5? z``KK*fPA?ZW14EncD?gW#}_JdWV0;aDetP==r0b{$jQr_W$a>X8u#V}|7I7He|NLI z7mjgO5UCH%Wqw_;Hh|~SG#rv`xc{D*TOe34$Q(g^0KiSfAOn^&xWHv z!SR6qpQ-EDO)7!aW=p7CSf(`CfngVhV5k!y)sU#uX2?>NG zB$Gx$>In%6NzR>2`bl%oy2Y}Vd)2k<>U#F0>$i%R|vm&}P#$+tEyl7nS3OSUZ$`9)Ea@E8`hr2!FZ*)xGSYsLU6s>W}lGh6S z|M31EoyE8{1tyQnF>IZ$_F&soy75VjucD2O@2|u>bafIRE^_@De!LkU@yx2t_WS9} z#UktvJo17)`D?ZE8*BBEWWb{@iDz@w(PCXR%m0_f{ZQ*{_?MdQSwetUQiFy^|o&KCofe*!JJi2ZIW?}Tq_mfsSJzCT@x51Lery0$c})5ag(9qruvaCIui z{*_)lgI=D+IIyBlbTrY**8(1GUCalzPGb8CxH2txrk=z-MEyuQkW=A{m`7;sz?ZyO zr;yKeeXDEXo1N(T9Pj3M8u{C7@D6y+FWIlus|jQ}4m|Gr3ULo=Aoy5`9Ffg zo9*{b;niubvA4YU{4cq>olg8vA5W(OeT+MM`Q@B%B}P zJ8juVj4L+rQP7KD$5MEFBXRla@_XkgJUx#u=EMIDzTS9Xtz&p`AU$W%=M=o}mD^-R zo}?|uQ_(J_;)BAO|Jc|1b%uaUC_U+p6}02v25K^Gxqgu!6C}N zkN+TfLx(Kze^DQw#%GuI#ZA|(ZzMAeh>d_{3_ZDUUtbO?k{&Kqx zuc*7pMezN^gs-Y}YpK7Llf|%t=V9tf1D{vXz@yEH=O_6I=qL002<74F=h6R+j%V4= zmctqP8`0YrE9s(r$rn1Oa{`Zbk@4TQ^cpg>aoL0DZ>W`X@Y#8Ey>Q2mY z#kakR`^ju>waRU>cb}TmcVGwpdy;!it*k5ACih{(@YzY9Uxd#1$nos|8M}<42l{ySory!_ zd(y#AW8iv9t~`xrI(6+=R}HIwNWmllre#AJhI*Xn?@PF_;A6|K%p5j{G*Up1)(s$^8s|)cR zi;sNjnojN@KIud^gJ0gjZ&9=So=414gnr#`&`;eP=xLfOc73w?eVMqtLeDw)E+^+D ze46qYUVjd~ct=~6KZ5>8o#p@T(@*339{;RDf4l1m+b==iiSGyW?Pb>muB%t|i(NaW zSro3$v0G@y*L-2yVwnn_9a=O?2GU!J(n1- z!aKIZZ`YoL&(Fc*IH+@6U4Kht?U{4Bci^#DDq@+IA`=|2_t zpGoegV4LX=Pfnq$SQKa2o)v@e1H4FGn+-c1-77G86YX-W(W$=c_%wQoZL&%{gU{1^ zz8F48nHl!WT!UAKHOsX=*{SRi!+a5a=~-!L3s%rY9#`H+JJ&UKbxo${YBVR)rI#J@ zDc=U0h1Xg#Pr_%TGHmJQx5~n1IgYM;3@uw*uerw0F_#(RvP6uld{!O7j?MV5ZkBVQ ztqR}lME`YiHa5%u1LS>lKOXHaG9Pt)Jz6o2@o#pVbA007uCd{c;D3;pbKooKqfMzC#I^_FgXQH6cu%wZrdz86cD*tcZU5WNHm}0@N0kxF z>?Qg7RtPf?gRghXAI|RBZer`MaDe3?+rgqyLbco7y0ud{Aa=T zvQ1G(KJ8r2#@guOZL!+}-lF4^=y56J|GRJ9N$>HnoA5aS{ZM-TSv!GuOMd59zz1n-Nl)mxIvTXa!Uq#;Z z$org6Tl`Yqo0{)x!D2oz*3ZTLd|jO=%#StXeTO;`ZAsimwZt~Q&DF16i?%J^{Ymd* z>tDp_$LQsJPai(U`pHxHe;=-m?%xS>EqPsx`5;*hcdfCDo;%>rz+XfErabtrJVqaF zMtpmy`z`e5UX!pR_y_$#z2!GzVvTEjTOy-N)VE~4XIKl+uQV?ETkSOc2j~;)KjK+N z{9`88bVc2dG8Rp>@11Tm?%9T(f4bS5+@+6KH{)4fe0!uZk6iwVIiP4uvMbqVE~{VO zr)%&!-0@bvd0Aap=XjjlS%Lnh&>235pL)e{DE{hC@_f5pPy6s$h2{zL^YJlO=-P?D zxv!Y3$nL;@7J73(Jv(aUJ2WwGoK*RK3Y>1y-sguo)>ot-g}?6jhisuwa;kH4amDw@ z9}WLKnvL*_@tueNMEJ{S)P=aeFRn*32mj;b!BcYXpj!JK&`;2920T;0J}Y;$b&aL+ z;X(A%;D6Hp%klKITKUhEdeo=M>BBl7ZZ{41L6S2b+(BUrDaI)jh)T>*)8U z+dkypjDC#mRrC$`pj!D~q2wNI<6!OJ1oUI*ev>;rzdZ#$+xPQ_;Jq7t7B|c9 z6~{L*Vy$A=Nc*s58GW8GZtJ1v$JHmg7xeC434d7re1PX$>>h{yOnl``%uV;KbKd9r zMEkqF1G5(Wz4&c*E>dbP?+V`B6KSTq<^((BIh3nc`G2nL*B*{2{lq ztH|HQzctvLTDfMnyAOV;+<>Qd@yk4R?TL5Po%84=$Lf3+^J&Qma#CH3dx%0V#hhKt zr?oC`miJ=u&A`?M<%g5|9{e8t<4*KPi;Mm`e44#a-!HZCR0t1EbH!B0$jY{_ExfJf3% z{T~Q_Y{X{`e44i6H*yQsAwJnwa>YHqi(brUGc4w#s&sG26N`w;b|J=r{qTOa#(i1& z9ilI>Az~04U&eh|axHrID6OC`H*)8|Pa(J7TaG7_oo)UG9_kN0SIB*G0}p-bQ7??$ zi$C_3^OnIU^zT-W;{S-nan1JgZg|*RKELvOymbW{ekkYn_@`W-?Rk1UqmTQC7*B*h zTT!o5&-$Jhi^%oJQ@R&V!z=E0M~mGu^nLixpwC0_k*mwK%pHr^#n0X18{hRyHld&8 zSlqiJF7hGz?pR|SYZa>UBp1iXpZdk{qpU+%K~H&AM9jVfKNWs~IohVOufgR>AwQGD zZCz{1abA5m->@O_xxN?t@ik>0rO%VO2411Rb~x%^^f|4E(3|{$SI^Ux{ zO+&A}%1(0aFYwv;|JpstWc0g{@fdus-u8bLmgD&bd$ou0ep&qIB6$`4>+YAeW2rh4 z@0XR|q}_aDEMZncN-9M}uM7v}6$cWAywN zea|<<7q9FD+u6P4_lIN7G3FSui1WGj7osodbR7JP=x6ZXRrqwmzUtcf;#wCQzDw_f z8%rdA$A339%kQW4EUlH-lbhj3s7rD>z2&bleiHvK_yv6b6h5i*wdh~8zgN4PYwKTz zUkH%wd>Y)mYU?TG)vHWSly!bKJ{ept*L&c`S{eS`Y|e)Cmg_(|R=~Gv ztLf9VnD2)3uR0WcP~2apo7#Drjv{wHS?ii*AKh4oW+nPpd$n75ZiK&RY_p1P>PIpT zZeR8_x3C*c4+w+!y6Die9iP85*Cj`L$S&XK)b2zbi~m4n zZ;^{Gn5aC;kSZ~Veiv?^)biaRQ^5p0c`II|BUDJz1#GO zwlZk z`G1a$|HIyNBUwY(HM807s|R_PHT<)M&X2J}{F`gT)`Y_K1NqU~-pGf4BG=fa^=bG` zcwN+Nw{vXC4wg?h(C6c}u4b8U$q(@HOf@-=KKH}FpskKN*@Ev&R_ORBJi~uBxj&JI zbT0OUeVL3gWKVt;Zq*_7d9$ovu|A@H20NpEjA)kg z=v`y@VSvnA;QW>0oh-#a>hEvS(=FD=4K(cClV|FC@_Xgg_4sa0I?;YDot}jM4?aQn zXX~oovX?KJ_9otE>zeBPe&esV?YGkDPWJL^*IdWbl$DV#z+~COzwetP2 zOuol@)qH<(rE_%z`lhnW;3s!lZ|Rur1Fzw)&U7t=_qtE`sJLxFKZ!o;9nXS)U<}fM zrWgHm^x@0N@T>Lh2a&s+ZFAYX8BO@yJKfpAcr2jzB6Wk^^RJ|~g8Mm#s6mm|ghXzNGVpG<#wRN;pfKPN}Bdn`S} zA1AbfJmPq}eZ=xKI@QJMJ!RITc|Iv;n>@*1 zs<9t_hWL(Ac23Q(mJXwpn-4#ePLVs)=((op8<4IqfuF_4v+;jJ8Fe?-I(96FpG~*L zY#qx#^1MDzIoDh84((ZeSj|`j-s3uct#jzSmc7q8$G00zwE0C2 z=jYOmkK-Nou6OCPfn5HGIiac;W|y;zJ;fF@@-aOh%`Uc_jBX}e*)I85xuA{5dX{vW z;~i+W)Bi%(@QHbylph<8BaaGrhqAGzrsECz!=pZD7uiD33F0>n4L;Rf)cwuZ<^{kio{}pW^V%c37047&(&G^olOx_ zelFnYRm$$i)>~}j;NnuRDRTn;6||{sI6l4N2#@a>#C%`ISFLB{jJRi4;4jY$JkryZ z|7@*%u9{p4|6*tPeZ8^{Uek_$9{2~=ygrToV8^H7zkp1#;vK-QA#B|npO4i1>(FuN zkZSA4*#Eb>{$u0x9Q{?!-_brzV9Sr-^VG*F=;4KSpqOgERlWbTcuqurHG1tvWj49` zw)n40tYggKWz6Uh_ZG<{-|rE(Yb@DSeYOs}VlI7e>Bn%?Y1y!kZ`9Pc!8?;7zL z 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/opus-surround-distinct-native.f32 b/tests/fixtures/media/opus-surround-distinct-native.f32 new file mode 100644 index 0000000000000000000000000000000000000000..8d03bf28e2afeb0391421e67e8699da629d0d334 GIT binary patch literal 276480 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

M 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 EABT9>m;e9( literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/opus-surround-distinct.opus b/tests/fixtures/media/opus-surround-distinct.opus new file mode 100644 index 0000000000000000000000000000000000000000..2ee10a079617871699dd67ed398716a4d0cb301d GIT binary patch literal 12777 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/opus-surround-padded-gain-native.f32 b/tests/fixtures/media/opus-surround-padded-gain-native.f32 new file mode 100644 index 0000000000000000000000000000000000000000..c68fdf830a4811035f6d68e3c84e83dca885f28e GIT binary patch literal 276480 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/speex-chained-multiplexed.spx b/tests/fixtures/media/speex-chained-multiplexed.spx new file mode 100644 index 0000000000000000000000000000000000000000..19c40697e9294ff033338f7626f8db1ff47f25c7 GIT binary patch literal 2768 zcmeZIPY-5bVt|6IhVA?y=B+ztnivCu3sO@n6ciK;^^EikVM?GhBajXN;s!eg1`wMC zh(Yv!AaDRNfB;HCI6%@L-L8I+)J-?3+cdHZZZpVVAlIzRuCilPkN~n=^OEyZQd1Pl zGfOfQARdK#6YNf8z5O7)K8r;k5~-J^fgPe9WG^Ui&%`A>V|Rl9gOC|Y7a15bUO+(O z&o3n<2_C&mY8XBrIAF)65Tm8Nb>(L7I<^`FxOVNnwQB>Exog+1?I2Um zGsk8u*>TI_N2JK9g2|aPm+Y9fL?+dO*TsMT;u|-dg64?IygBP6r7I?Foq*7aYD$yB z`6)7U7MQ+Ra^i>S1RR%9f}GD!*UTzAeg5PN;oBp5!$Nx4oixOaZ!OXRXLY zgASKH(tK#DA)~lH54m;?kk~WvWH9k;!(CtuKDK=Rc`YQBJH_ zvu59FTmKDNYV5mjJJdB~H9PdK$43T=_ kc?)DO$iv4?ZSIWbtr3~GkkbOVoRwvo!Zex|Mp{|`0JQUKLjV8( literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/speex-chained.spx b/tests/fixtures/media/speex-chained.spx new file mode 100644 index 0000000000000000000000000000000000000000..f3a517338f209152a7b5791e3bdbeb1ade0111f9 GIT binary patch literal 1384 zcmeZIPY-5bVt|6IhVA?y=B+ztnivCu3sO@n6ciK;^^EikVM?GhBajXN;s!eg1`wMC zh(Yv!AaDRNfB;HCI6%@LWEUKO>;kD+mtAGYs2~Akx#lJ3r=+GRlxLP?C_pTSTMgFB z(!dVU4$=#9^qIJXXY6heU=T7x=^_I|#tR5&{Q0G%B*CM1Ne#p20|)H56k@crm%Lsy zz18TUv##9iUB^~~0N1YFw{~rSGI#CTwH;*2dFI%RB|C0e{D>4eRWLbo=8_%LmdK=9 z@VfZ#Uwq?+Q_vhynKx&hq;$oktrHMhQB7%5I6p;Z&H~dHOHTYSy*#TcsA;WCO4$K2u?4yQ#9ZVG&TYm)4YFxTR>PC&)lGMQP1?p?8?4+!W+9w=jn)pvu zSCSj2TUc|i_tVY($Xvd;w3i5 literal 0 HcmV?d00001 diff --git a/tests/fixtures/media/speex-multiplexed.spx b/tests/fixtures/media/speex-multiplexed.spx new file mode 100644 index 0000000000000000000000000000000000000000..70b19fe9157fbc24229e68ddb5d1ef8decbff696 GIT binary patch literal 1384 zcmeZIPY-5bVt|6IhVA?y=B+ztnivCu3sO@n6ciK;^^EikVM?GhBajXN;s!eg1`wMC zh(Yv!AaDRNfB;HCI6%@L-L8I+)J-?3+cdHZZZpVVAlIzRuCilPkN~n=^OEyZQd1Pl zGfOfQARdK#6YNf8z5O7)K8r;k5~-J^fgPe9WG^Ui&%`A>V|Rl9gOC|Y7a15bUO+(O z&o3n<2_C&mY8XBrIAF)65Tm8Nb>(L7I<^`FxOVNnwQB>Exog+1?I2Um zGsk8u*>TI_N2JK9g2|aPm+Y9fL?+dO*TsMT;u|-dg64?IygBP6r7I?Foq*7aYD$yB z`6)7U7MQ+Ra^i>S1RR%9f}GD!*UTzAeg5PN;oBp5!$Nx4oixOaZ!OXRXLY zgASKH(tK#DA)~lH54m;?kk~WvWH9k;!(CtuKDK=Rc`YQBJH_ zvu59FTmKDNYV5mjJJdB~H9PdK", + "Kostya Shishkov ", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Pure Rust AAC decoder (a part of project Symphonia)." +homepage = "https://github.com/pdeljanov/Symphonia" +readme = "README.md" +keywords = [ + "audio", + "codec", + "decoder", + "aac", + "m4a", +] +categories = [ + "multimedia", + "multimedia::audio", + "multimedia::encoding", +] +license = "MPL-2.0" +repository = "https://github.com/pdeljanov/Symphonia" +resolver = "2" + +[lib] +name = "symphonia_codec_aac" +path = "src/lib.rs" + +[[test]] +name = "tests" +path = "tests/tests.rs" + +[dependencies.lazy_static] +version = "1.4.0" + +[dependencies.log] +version = "0.4" + +[dependencies.symphonia-common] +version = "0.6.1" + +[dependencies.symphonia-core] +version = "0.6.1" + +[lints.clippy] +unwrap_used = "warn" + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = ["cfg(fuzzing)"] diff --git a/vendor/symphonia-codec-aac/LICENSE b/vendor/symphonia-codec-aac/LICENSE new file mode 100644 index 00000000..f4bbcd20 --- /dev/null +++ b/vendor/symphonia-codec-aac/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/vendor/symphonia-codec-aac/README.md b/vendor/symphonia-codec-aac/README.md new file mode 100644 index 00000000..fa50f9a8 --- /dev/null +++ b/vendor/symphonia-codec-aac/README.md @@ -0,0 +1,24 @@ +# Symphonia AAC Codec + +[Docs.rs](https://docs.rs/symphonia-codec-aac) + +Advanced Audio Coding (AAC) decoder for Project Symphonia. + +> [!NOTE] +> This crate is part of Symphonia. Please use the [`symphonia`](https://crates.io/crates/symphonia) crate instead of this one directly. + +## Support + +This decoder implements the low-complexity (LC) profile as defined in ISO/IEC 14496-3. + +## Attribution + +Symphonia's AAC decoder was ported and relicensed from the [NihAV](https://nihav.org/) project with permission from the original author, Kostya Shishkov. The first commit with the original decoder is `3aeeb22`. + +## License + +Symphonia is provided under the MPL v2.0 license. Please refer to the LICENSE file for more details. + +## Contributing + +Symphonia is a free and open-source project that welcomes contributions! To get started, please read our [Contribution Guidelines](https://github.com/pdeljanov/Symphonia/blob/main/CONTRIBUTING.md). diff --git a/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md b/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md new file mode 100644 index 00000000..9193ddb4 --- /dev/null +++ b/vendor/symphonia-codec-aac/RUSTCHAN-PATCH.md @@ -0,0 +1,7 @@ +# RustChan AAC patch + +Source: the crates.io `symphonia-codec-aac` 0.6.1 release, from [Symphonia](https://github.com/pdeljanov/Symphonia), licensed MPL-2.0. The upstream package archive checksum is `f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c`. Original source, manifest, tests and license are preserved. No additional dependencies, native code or build script are introduced. + +The only source change is in `src/aac/mod.rs`: after parsing a complete raw-data block, return `Unsupported("aac: spectral band replication is unsupported")` when the existing parser has detected an in-band SBR fill element. Upstream otherwise synthesizes only AAC-LC core samples, silently changing HE-AAC rate, duration and bandwidth. RustChan recognizes this exact error as an explicit compatibility requirement. Truncated payloads keep their existing parse errors. + +This patch detects a missing decoder capability; it does not implement SBR or parametric stereo. Remove the patch after an upstream release provides correct reconstruction or this same explicit error. The upstream crate requires Rust 1.85; RustChan still requires 1.99. diff --git a/vendor/symphonia-codec-aac/src/aac/codebooks.rs b/vendor/symphonia-codec-aac/src/aac/codebooks.rs new file mode 100644 index 00000000..0aadf3ef --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/codebooks.rs @@ -0,0 +1,669 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::io::{ReadBitsLtr, vlc::*}; + +use lazy_static::lazy_static; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK1_LENS: [u8; 81] = [ + 11, 9, 11, 10, 7, 10, 11, 9, 11, 10, 7, 10, 7, 5, 7, 9, + 7, 10, 11, 9, 11, 9, 7, 9, 11, 9, 11, 9, 7, 9, 7, 5, + 7, 9, 7, 9, 7, 5, 7, 5, 1, 5, 7, 5, 7, 9, 7, 9, + 7, 5, 7, 9, 7, 9, 11, 9, 11, 9, 7, 9, 11, 9, 11, 10, + 7, 9, 7, 5, 7, 9, 7, 10, 11, 9, 11, 10, 7, 9, 11, 9, + 11 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK1_CODES: [u32; 81] = [ + 0x7f8, 0x1f1, 0x7fd, 0x3f5, 0x068, 0x3f0, 0x7f7, 0x1ec, + 0x7f5, 0x3f1, 0x072, 0x3f4, 0x074, 0x011, 0x076, 0x1eb, + 0x06c, 0x3f6, 0x7fc, 0x1e1, 0x7f1, 0x1f0, 0x061, 0x1f6, + 0x7f2, 0x1ea, 0x7fb, 0x1f2, 0x069, 0x1ed, 0x077, 0x017, + 0x06f, 0x1e6, 0x064, 0x1e5, 0x067, 0x015, 0x062, 0x012, + 0x000, 0x014, 0x065, 0x016, 0x06d, 0x1e9, 0x063, 0x1e4, + 0x06b, 0x013, 0x071, 0x1e3, 0x070, 0x1f3, 0x7fe, 0x1e7, + 0x7f3, 0x1ef, 0x060, 0x1ee, 0x7f0, 0x1e2, 0x7fa, 0x3f3, + 0x06a, 0x1e8, 0x075, 0x010, 0x073, 0x1f4, 0x06e, 0x3f7, + 0x7f6, 0x1e0, 0x7f9, 0x3f2, 0x066, 0x1f5, 0x7ff, 0x1f7, + 0x7f4 +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK2_LENS: [u8; 81] = [ + 9, 7, 9, 8, 6, 8, 9, 8, 9, 8, 6, 7, 6, 5, 6, 7, + 6, 8, 9, 7, 8, 8, 6, 8, 9, 7, 9, 8, 6, 7, 6, 5, + 6, 7, 6, 8, 6, 5, 6, 5, 3, 5, 6, 5, 6, 8, 6, 7, + 6, 5, 6, 8, 6, 8, 9, 7, 9, 8, 6, 8, 8, 7, 9, 8, + 6, 7, 6, 4, 6, 8, 6, 7, 9, 7, 9, 7, 6, 8, 9, 7, + 9 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK2_CODES: [u32; 81] = [ + 0x1f3, 0x06f, 0x1fd, 0x0eb, 0x023, 0x0ea, 0x1f7, 0x0e8, + 0x1fa, 0x0f2, 0x02d, 0x070, 0x020, 0x006, 0x02b, 0x06e, + 0x028, 0x0e9, 0x1f9, 0x066, 0x0f8, 0x0e7, 0x01b, 0x0f1, + 0x1f4, 0x06b, 0x1f5, 0x0ec, 0x02a, 0x06c, 0x02c, 0x00a, + 0x027, 0x067, 0x01a, 0x0f5, 0x024, 0x008, 0x01f, 0x009, + 0x000, 0x007, 0x01d, 0x00b, 0x030, 0x0ef, 0x01c, 0x064, + 0x01e, 0x00c, 0x029, 0x0f3, 0x02f, 0x0f0, 0x1fc, 0x071, + 0x1f2, 0x0f4, 0x021, 0x0e6, 0x0f7, 0x068, 0x1f8, 0x0ee, + 0x022, 0x065, 0x031, 0x002, 0x026, 0x0ed, 0x025, 0x06a, + 0x1fb, 0x072, 0x1fe, 0x069, 0x02e, 0x0f6, 0x1ff, 0x06d, + 0x1f6 +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK3_LENS: [u8; 81] = [ + 1, 4, 8, 4, 5, 8, 9, 9, 10, 4, 6, 9, 6, 6, 9, 9, + 9, 10, 9, 10, 13, 9, 9, 11, 11, 10, 12, 4, 6, 10, 6, 7, + 10, 10, 10, 12, 5, 7, 11, 6, 7, 10, 9, 9, 11, 9, 10, 13, + 8, 9, 12, 10, 11, 12, 8, 10, 15, 9, 11, 15, 13, 14, 16, 8, + 10, 14, 9, 10, 14, 12, 12, 15, 11, 12, 16, 10, 11, 15, 12, 12, + 15 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK3_CODES: [u32; 81] = [ + 0x0000, 0x0009, 0x00ef, 0x000b, 0x0019, 0x00f0, 0x01eb, 0x01e6, + 0x03f2, 0x000a, 0x0035, 0x01ef, 0x0034, 0x0037, 0x01e9, 0x01ed, + 0x01e7, 0x03f3, 0x01ee, 0x03ed, 0x1ffa, 0x01ec, 0x01f2, 0x07f9, + 0x07f8, 0x03f8, 0x0ff8, 0x0008, 0x0038, 0x03f6, 0x0036, 0x0075, + 0x03f1, 0x03eb, 0x03ec, 0x0ff4, 0x0018, 0x0076, 0x07f4, 0x0039, + 0x0074, 0x03ef, 0x01f3, 0x01f4, 0x07f6, 0x01e8, 0x03ea, 0x1ffc, + 0x00f2, 0x01f1, 0x0ffb, 0x03f5, 0x07f3, 0x0ffc, 0x00ee, 0x03f7, + 0x7ffe, 0x01f0, 0x07f5, 0x7ffd, 0x1ffb, 0x3ffa, 0xffff, 0x00f1, + 0x03f0, 0x3ffc, 0x01ea, 0x03ee, 0x3ffb, 0x0ff6, 0x0ffa, 0x7ffc, + 0x07f2, 0x0ff5, 0xfffe, 0x03f4, 0x07f7, 0x7ffb, 0x0ff7, 0x0ff9, + 0x7ffa +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK4_LENS: [u8; 81] = [ + 4, 5, 8, 5, 4, 8, 9, 8, 11, 5, 5, 8, 5, 4, 8, 8, + 7, 10, 9, 8, 11, 8, 8, 10, 11, 10, 11, 4, 5, 8, 4, 4, + 8, 8, 8, 10, 4, 4, 8, 4, 4, 7, 8, 7, 9, 8, 8, 10, + 7, 7, 9, 10, 9, 10, 8, 8, 11, 8, 7, 10, 11, 10, 12, 8, + 7, 10, 7, 7, 9, 10, 9, 11, 11, 10, 12, 10, 9, 11, 11, 10, + 11 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK4_CODES: [u32; 81] = [ + 0x007, 0x016, 0x0f6, 0x018, 0x008, 0x0ef, 0x1ef, 0x0f3, + 0x7f8, 0x019, 0x017, 0x0ed, 0x015, 0x001, 0x0e2, 0x0f0, + 0x070, 0x3f0, 0x1ee, 0x0f1, 0x7fa, 0x0ee, 0x0e4, 0x3f2, + 0x7f6, 0x3ef, 0x7fd, 0x005, 0x014, 0x0f2, 0x009, 0x004, + 0x0e5, 0x0f4, 0x0e8, 0x3f4, 0x006, 0x002, 0x0e7, 0x003, + 0x000, 0x06b, 0x0e3, 0x069, 0x1f3, 0x0eb, 0x0e6, 0x3f6, + 0x06e, 0x06a, 0x1f4, 0x3ec, 0x1f0, 0x3f9, 0x0f5, 0x0ec, + 0x7fb, 0x0ea, 0x06f, 0x3f7, 0x7f9, 0x3f3, 0xfff, 0x0e9, + 0x06d, 0x3f8, 0x06c, 0x068, 0x1f5, 0x3ee, 0x1f2, 0x7f4, + 0x7f7, 0x3f1, 0xffe, 0x3ed, 0x1f1, 0x7f5, 0x7fe, 0x3f5, + 0x7fc +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK5_LENS: [u8; 81] = [ + 13, 12, 11, 11, 10, 11, 11, 12, 13, 12, 11, 10, 9, 8, 9, 10, + 11, 12, 12, 10, 9, 8, 7, 8, 9, 10, 11, 11, 9, 8, 5, 4, + 5, 8, 9, 11, 10, 8, 7, 4, 1, 4, 7, 8, 11, 11, 9, 8, + 5, 4, 5, 8, 9, 11, 11, 10, 9, 8, 7, 8, 9, 10, 11, 12, + 11, 10, 9, 8, 9, 10, 11, 12, 13, 12, 12, 11, 10, 10, 11, 12, + 13 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK5_CODES: [u32; 81] = [ + 0x1fff, 0x0ff7, 0x07f4, 0x07e8, 0x03f1, 0x07ee, 0x07f9, 0x0ff8, + 0x1ffd, 0x0ffd, 0x07f1, 0x03e8, 0x01e8, 0x00f0, 0x01ec, 0x03ee, + 0x07f2, 0x0ffa, 0x0ff4, 0x03ef, 0x01f2, 0x00e8, 0x0070, 0x00ec, + 0x01f0, 0x03ea, 0x07f3, 0x07eb, 0x01eb, 0x00ea, 0x001a, 0x0008, + 0x0019, 0x00ee, 0x01ef, 0x07ed, 0x03f0, 0x00f2, 0x0073, 0x000b, + 0x0000, 0x000a, 0x0071, 0x00f3, 0x07e9, 0x07ef, 0x01ee, 0x00ef, + 0x0018, 0x0009, 0x001b, 0x00eb, 0x01e9, 0x07ec, 0x07f6, 0x03eb, + 0x01f3, 0x00ed, 0x0072, 0x00e9, 0x01f1, 0x03ed, 0x07f7, 0x0ff6, + 0x07f0, 0x03e9, 0x01ed, 0x00f1, 0x01ea, 0x03ec, 0x07f8, 0x0ff9, + 0x1ffc, 0x0ffc, 0x0ff5, 0x07ea, 0x03f3, 0x03f2, 0x07f5, 0x0ffb, + 0x1ffe +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK6_LENS: [u8; 81] = [ + 11, 10, 9, 9, 9, 9, 9, 10, 11, 10, 9, 8, 7, 7, 7, 8, + 9, 10, 9, 8, 6, 6, 6, 6, 6, 8, 9, 9, 7, 6, 4, 4, + 4, 6, 7, 9, 9, 7, 6, 4, 4, 4, 6, 7, 9, 9, 7, 6, + 4, 4, 4, 6, 7, 9, 9, 8, 6, 6, 6, 6, 6, 8, 9, 10, + 9, 8, 7, 7, 7, 7, 8, 10, 11, 10, 9, 9, 9, 9, 9, 10, + 11 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK6_CODES: [u32; 81] = [ + 0x7fe, 0x3fd, 0x1f1, 0x1eb, 0x1f4, 0x1ea, 0x1f0, 0x3fc, + 0x7fd, 0x3f6, 0x1e5, 0x0ea, 0x06c, 0x071, 0x068, 0x0f0, + 0x1e6, 0x3f7, 0x1f3, 0x0ef, 0x032, 0x027, 0x028, 0x026, + 0x031, 0x0eb, 0x1f7, 0x1e8, 0x06f, 0x02e, 0x008, 0x004, + 0x006, 0x029, 0x06b, 0x1ee, 0x1ef, 0x072, 0x02d, 0x002, + 0x000, 0x003, 0x02f, 0x073, 0x1fa, 0x1e7, 0x06e, 0x02b, + 0x007, 0x001, 0x005, 0x02c, 0x06d, 0x1ec, 0x1f9, 0x0ee, + 0x030, 0x024, 0x02a, 0x025, 0x033, 0x0ec, 0x1f2, 0x3f8, + 0x1e4, 0x0ed, 0x06a, 0x070, 0x069, 0x074, 0x0f1, 0x3fa, + 0x7ff, 0x3f9, 0x1f6, 0x1ed, 0x1f8, 0x1e9, 0x1f5, 0x3fb, + 0x7fc +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK7_LENS: [u8; 64] = [ + 1, 3, 6, 7, 8, 9, 10, 11, 3, 4, 6, 7, 8, 8, 9, 9, + 6, 6, 7, 8, 8, 9, 9, 10, 7, 7, 8, 8, 9, 9, 10, 10, + 8, 8, 9, 9, 10, 10, 10, 11, 9, 8, 9, 9, 10, 10, 11, 11, + 10, 9, 9, 10, 10, 11, 12, 12, 11, 10, 10, 10, 11, 11, 12, 12 +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK7_CODES: [u32; 64] = [ + 0x000, 0x005, 0x037, 0x074, 0x0f2, 0x1eb, 0x3ed, 0x7f7, + 0x004, 0x00c, 0x035, 0x071, 0x0ec, 0x0ee, 0x1ee, 0x1f5, + 0x036, 0x034, 0x072, 0x0ea, 0x0f1, 0x1e9, 0x1f3, 0x3f5, + 0x073, 0x070, 0x0eb, 0x0f0, 0x1f1, 0x1f0, 0x3ec, 0x3fa, + 0x0f3, 0x0ed, 0x1e8, 0x1ef, 0x3ef, 0x3f1, 0x3f9, 0x7fb, + 0x1ed, 0x0ef, 0x1ea, 0x1f2, 0x3f3, 0x3f8, 0x7f9, 0x7fc, + 0x3ee, 0x1ec, 0x1f4, 0x3f4, 0x3f7, 0x7f8, 0xffd, 0xffe, + 0x7f6, 0x3f0, 0x3f2, 0x3f6, 0x7fa, 0x7fd, 0xffc, 0xfff +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK8_LENS: [u8; 64] = [ + 5, 4, 5, 6, 7, 8, 9, 10, 4, 3, 4, 5, 6, 7, 7, 8, + 5, 4, 4, 5, 6, 7, 7, 8, 6, 5, 5, 6, 6, 7, 8, 8, + 7, 6, 6, 6, 7, 7, 8, 9, 8, 7, 6, 7, 7, 8, 8, 10, + 9, 7, 7, 8, 8, 8, 9, 9, 10, 8, 8, 8, 9, 9, 9, 10 +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK8_CODES: [u32; 64] = [ + 0x00e, 0x005, 0x010, 0x030, 0x06f, 0x0f1, 0x1fa, 0x3fe, + 0x003, 0x000, 0x004, 0x012, 0x02c, 0x06a, 0x075, 0x0f8, + 0x00f, 0x002, 0x006, 0x014, 0x02e, 0x069, 0x072, 0x0f5, + 0x02f, 0x011, 0x013, 0x02a, 0x032, 0x06c, 0x0ec, 0x0fa, + 0x071, 0x02b, 0x02d, 0x031, 0x06d, 0x070, 0x0f2, 0x1f9, + 0x0ef, 0x068, 0x033, 0x06b, 0x06e, 0x0ee, 0x0f9, 0x3fc, + 0x1f8, 0x074, 0x073, 0x0ed, 0x0f0, 0x0f6, 0x1f6, 0x1fd, + 0x3fd, 0x0f3, 0x0f4, 0x0f7, 0x1f7, 0x1fb, 0x1fc, 0x3ff +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK9_LENS: [u8; 169] = [ + 1, 3, 6, 8, 9, 10, 10, 11, 11, 12, 12, 13, 13, 3, 4, 6, + 7, 8, 8, 9, 10, 10, 10, 11, 12, 12, 6, 6, 7, 8, 8, 9, + 10, 10, 10, 11, 12, 12, 12, 8, 7, 8, 9, 9, 10, 10, 11, 11, + 11, 12, 12, 13, 9, 8, 9, 9, 10, 10, 11, 11, 11, 12, 12, 12, + 13, 10, 9, 9, 10, 11, 11, 11, 12, 11, 12, 12, 13, 13, 11, 9, + 10, 11, 11, 11, 12, 12, 12, 12, 13, 13, 13, 11, 10, 10, 11, 11, + 12, 12, 13, 13, 13, 13, 13, 13, 11, 10, 10, 11, 11, 11, 12, 12, + 13, 13, 14, 13, 14, 11, 10, 11, 11, 12, 12, 12, 12, 13, 13, 14, + 14, 14, 12, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 12, + 11, 12, 12, 12, 13, 13, 13, 13, 14, 14, 15, 15, 13, 12, 12, 12, + 13, 13, 13, 13, 14, 14, 14, 14, 15 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK9_CODES: [u32; 169] = [ + 0x0000, 0x0005, 0x0037, 0x00e7, 0x01de, 0x03ce, 0x03d9, 0x07c8, + 0x07cd, 0x0fc8, 0x0fdd, 0x1fe4, 0x1fec, 0x0004, 0x000c, 0x0035, + 0x0072, 0x00ea, 0x00ed, 0x01e2, 0x03d1, 0x03d3, 0x03e0, 0x07d8, + 0x0fcf, 0x0fd5, 0x0036, 0x0034, 0x0071, 0x00e8, 0x00ec, 0x01e1, + 0x03cf, 0x03dd, 0x03db, 0x07d0, 0x0fc7, 0x0fd4, 0x0fe4, 0x00e6, + 0x0070, 0x00e9, 0x01dd, 0x01e3, 0x03d2, 0x03dc, 0x07cc, 0x07ca, + 0x07de, 0x0fd8, 0x0fea, 0x1fdb, 0x01df, 0x00eb, 0x01dc, 0x01e6, + 0x03d5, 0x03de, 0x07cb, 0x07dd, 0x07dc, 0x0fcd, 0x0fe2, 0x0fe7, + 0x1fe1, 0x03d0, 0x01e0, 0x01e4, 0x03d6, 0x07c5, 0x07d1, 0x07db, + 0x0fd2, 0x07e0, 0x0fd9, 0x0feb, 0x1fe3, 0x1fe9, 0x07c4, 0x01e5, + 0x03d7, 0x07c6, 0x07cf, 0x07da, 0x0fcb, 0x0fda, 0x0fe3, 0x0fe9, + 0x1fe6, 0x1ff3, 0x1ff7, 0x07d3, 0x03d8, 0x03e1, 0x07d4, 0x07d9, + 0x0fd3, 0x0fde, 0x1fdd, 0x1fd9, 0x1fe2, 0x1fea, 0x1ff1, 0x1ff6, + 0x07d2, 0x03d4, 0x03da, 0x07c7, 0x07d7, 0x07e2, 0x0fce, 0x0fdb, + 0x1fd8, 0x1fee, 0x3ff0, 0x1ff4, 0x3ff2, 0x07e1, 0x03df, 0x07c9, + 0x07d6, 0x0fca, 0x0fd0, 0x0fe5, 0x0fe6, 0x1feb, 0x1fef, 0x3ff3, + 0x3ff4, 0x3ff5, 0x0fe0, 0x07ce, 0x07d5, 0x0fc6, 0x0fd1, 0x0fe1, + 0x1fe0, 0x1fe8, 0x1ff0, 0x3ff1, 0x3ff8, 0x3ff6, 0x7ffc, 0x0fe8, + 0x07df, 0x0fc9, 0x0fd7, 0x0fdc, 0x1fdc, 0x1fdf, 0x1fed, 0x1ff5, + 0x3ff9, 0x3ffb, 0x7ffd, 0x7ffe, 0x1fe7, 0x0fcc, 0x0fd6, 0x0fdf, + 0x1fde, 0x1fda, 0x1fe5, 0x1ff2, 0x3ffa, 0x3ff7, 0x3ffc, 0x3ffd, + 0x7fff +]; + +#[rustfmt::skip] +const SPECTRUM_CODEBOOK10_LENS: [u8; 169] = [ + 6, 5, 6, 6, 7, 8, 9, 10, 10, 10, 11, 11, 12, 5, 4, 4, + 5, 6, 7, 7, 8, 8, 9, 10, 10, 11, 6, 4, 5, 5, 6, 6, + 7, 8, 8, 9, 9, 10, 10, 6, 5, 5, 5, 6, 7, 7, 8, 8, + 9, 9, 10, 10, 7, 6, 6, 6, 6, 7, 7, 8, 8, 9, 9, 10, + 10, 8, 7, 6, 7, 7, 7, 8, 8, 8, 9, 10, 10, 11, 9, 7, + 7, 7, 7, 8, 8, 9, 9, 9, 10, 10, 11, 9, 8, 8, 8, 8, + 8, 9, 9, 9, 10, 10, 11, 11, 9, 8, 8, 8, 8, 8, 9, 9, + 10, 10, 10, 11, 11, 10, 9, 9, 9, 9, 9, 9, 10, 10, 10, 11, + 11, 12, 10, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, 12, 11, + 10, 9, 10, 10, 10, 10, 10, 11, 11, 11, 11, 12, 11, 10, 10, 10, + 10, 10, 10, 11, 11, 12, 12, 12, 12 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK10_CODES: [u32; 169] = [ + 0x022, 0x008, 0x01d, 0x026, 0x05f, 0x0d3, 0x1cf, 0x3d0, + 0x3d7, 0x3ed, 0x7f0, 0x7f6, 0xffd, 0x007, 0x000, 0x001, + 0x009, 0x020, 0x054, 0x060, 0x0d5, 0x0dc, 0x1d4, 0x3cd, + 0x3de, 0x7e7, 0x01c, 0x002, 0x006, 0x00c, 0x01e, 0x028, + 0x05b, 0x0cd, 0x0d9, 0x1ce, 0x1dc, 0x3d9, 0x3f1, 0x025, + 0x00b, 0x00a, 0x00d, 0x024, 0x057, 0x061, 0x0cc, 0x0dd, + 0x1cc, 0x1de, 0x3d3, 0x3e7, 0x05d, 0x021, 0x01f, 0x023, + 0x027, 0x059, 0x064, 0x0d8, 0x0df, 0x1d2, 0x1e2, 0x3dd, + 0x3ee, 0x0d1, 0x055, 0x029, 0x056, 0x058, 0x062, 0x0ce, + 0x0e0, 0x0e2, 0x1da, 0x3d4, 0x3e3, 0x7eb, 0x1c9, 0x05e, + 0x05a, 0x05c, 0x063, 0x0ca, 0x0da, 0x1c7, 0x1ca, 0x1e0, + 0x3db, 0x3e8, 0x7ec, 0x1e3, 0x0d2, 0x0cb, 0x0d0, 0x0d7, + 0x0db, 0x1c6, 0x1d5, 0x1d8, 0x3ca, 0x3da, 0x7ea, 0x7f1, + 0x1e1, 0x0d4, 0x0cf, 0x0d6, 0x0de, 0x0e1, 0x1d0, 0x1d6, + 0x3d1, 0x3d5, 0x3f2, 0x7ee, 0x7fb, 0x3e9, 0x1cd, 0x1c8, + 0x1cb, 0x1d1, 0x1d7, 0x1df, 0x3cf, 0x3e0, 0x3ef, 0x7e6, + 0x7f8, 0xffa, 0x3eb, 0x1dd, 0x1d3, 0x1d9, 0x1db, 0x3d2, + 0x3cc, 0x3dc, 0x3ea, 0x7ed, 0x7f3, 0x7f9, 0xff9, 0x7f2, + 0x3ce, 0x1e4, 0x3cb, 0x3d8, 0x3d6, 0x3e2, 0x3e5, 0x7e8, + 0x7f4, 0x7f5, 0x7f7, 0xffb, 0x7fa, 0x3ec, 0x3df, 0x3e1, + 0x3e4, 0x3e6, 0x3f0, 0x7e9, 0x7ef, 0xff8, 0xffe, 0xffc, + 0xfff +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK11_LENS: [u8; 289] = [ + 4, 5, 6, 7, 8, 8, 9, 10, 10, 10, 11, 11, 12, 11, 12, 12, + 10, 5, 4, 5, 6, 7, 7, 8, 8, 9, 9, 9, 10, 10, 10, 10, + 11, 8, 6, 5, 5, 6, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, + 10, 10, 8, 7, 6, 6, 6, 7, 7, 8, 8, 8, 9, 9, 9, 10, + 10, 10, 10, 8, 8, 7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, + 10, 10, 10, 10, 8, 8, 7, 7, 7, 7, 8, 8, 8, 9, 9, 9, + 9, 10, 10, 10, 10, 8, 9, 8, 8, 8, 8, 8, 8, 8, 9, 9, + 9, 10, 10, 10, 10, 10, 8, 9, 8, 8, 8, 8, 8, 8, 9, 9, + 9, 10, 10, 10, 10, 10, 10, 8, 10, 9, 8, 8, 9, 9, 9, 9, + 9, 10, 10, 10, 10, 10, 10, 11, 8, 10, 9, 9, 9, 9, 9, 9, + 9, 10, 10, 10, 10, 10, 10, 11, 11, 8, 11, 9, 9, 9, 9, 9, + 9, 10, 10, 10, 10, 10, 11, 10, 11, 11, 8, 11, 10, 9, 9, 10, + 9, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 8, 11, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 9, 11, 10, 9, + 9, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 9, 11, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 9, 12, + 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 12, 12, 9, + 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, + 5 +]; + +#[rustfmt::skip] +static SPECTRUM_CODEBOOK11_CODES: [u32; 289] = [ + 0x000, 0x006, 0x019, 0x03d, 0x09c, 0x0c6, 0x1a7, 0x390, + 0x3c2, 0x3df, 0x7e6, 0x7f3, 0xffb, 0x7ec, 0xffa, 0xffe, + 0x38e, 0x005, 0x001, 0x008, 0x014, 0x037, 0x042, 0x092, + 0x0af, 0x191, 0x1a5, 0x1b5, 0x39e, 0x3c0, 0x3a2, 0x3cd, + 0x7d6, 0x0ae, 0x017, 0x007, 0x009, 0x018, 0x039, 0x040, + 0x08e, 0x0a3, 0x0b8, 0x199, 0x1ac, 0x1c1, 0x3b1, 0x396, + 0x3be, 0x3ca, 0x09d, 0x03c, 0x015, 0x016, 0x01a, 0x03b, + 0x044, 0x091, 0x0a5, 0x0be, 0x196, 0x1ae, 0x1b9, 0x3a1, + 0x391, 0x3a5, 0x3d5, 0x094, 0x09a, 0x036, 0x038, 0x03a, + 0x041, 0x08c, 0x09b, 0x0b0, 0x0c3, 0x19e, 0x1ab, 0x1bc, + 0x39f, 0x38f, 0x3a9, 0x3cf, 0x093, 0x0bf, 0x03e, 0x03f, + 0x043, 0x045, 0x09e, 0x0a7, 0x0b9, 0x194, 0x1a2, 0x1ba, + 0x1c3, 0x3a6, 0x3a7, 0x3bb, 0x3d4, 0x09f, 0x1a0, 0x08f, + 0x08d, 0x090, 0x098, 0x0a6, 0x0b6, 0x0c4, 0x19f, 0x1af, + 0x1bf, 0x399, 0x3bf, 0x3b4, 0x3c9, 0x3e7, 0x0a8, 0x1b6, + 0x0ab, 0x0a4, 0x0aa, 0x0b2, 0x0c2, 0x0c5, 0x198, 0x1a4, + 0x1b8, 0x38c, 0x3a4, 0x3c4, 0x3c6, 0x3dd, 0x3e8, 0x0ad, + 0x3af, 0x192, 0x0bd, 0x0bc, 0x18e, 0x197, 0x19a, 0x1a3, + 0x1b1, 0x38d, 0x398, 0x3b7, 0x3d3, 0x3d1, 0x3db, 0x7dd, + 0x0b4, 0x3de, 0x1a9, 0x19b, 0x19c, 0x1a1, 0x1aa, 0x1ad, + 0x1b3, 0x38b, 0x3b2, 0x3b8, 0x3ce, 0x3e1, 0x3e0, 0x7d2, + 0x7e5, 0x0b7, 0x7e3, 0x1bb, 0x1a8, 0x1a6, 0x1b0, 0x1b2, + 0x1b7, 0x39b, 0x39a, 0x3ba, 0x3b5, 0x3d6, 0x7d7, 0x3e4, + 0x7d8, 0x7ea, 0x0ba, 0x7e8, 0x3a0, 0x1bd, 0x1b4, 0x38a, + 0x1c4, 0x392, 0x3aa, 0x3b0, 0x3bc, 0x3d7, 0x7d4, 0x7dc, + 0x7db, 0x7d5, 0x7f0, 0x0c1, 0x7fb, 0x3c8, 0x3a3, 0x395, + 0x39d, 0x3ac, 0x3ae, 0x3c5, 0x3d8, 0x3e2, 0x3e6, 0x7e4, + 0x7e7, 0x7e0, 0x7e9, 0x7f7, 0x190, 0x7f2, 0x393, 0x1be, + 0x1c0, 0x394, 0x397, 0x3ad, 0x3c3, 0x3c1, 0x3d2, 0x7da, + 0x7d9, 0x7df, 0x7eb, 0x7f4, 0x7fa, 0x195, 0x7f8, 0x3bd, + 0x39c, 0x3ab, 0x3a8, 0x3b3, 0x3b9, 0x3d0, 0x3e3, 0x3e5, + 0x7e2, 0x7de, 0x7ed, 0x7f1, 0x7f9, 0x7fc, 0x193, 0xffd, + 0x3dc, 0x3b6, 0x3c7, 0x3cc, 0x3cb, 0x3d9, 0x3da, 0x7d3, + 0x7e1, 0x7ee, 0x7ef, 0x7f5, 0x7f6, 0xffc, 0xfff, 0x19d, + 0x1c2, 0x0b5, 0x0a1, 0x096, 0x097, 0x095, 0x099, 0x0a0, + 0x0a2, 0x0ac, 0x0a9, 0x0b1, 0x0b3, 0x0bb, 0x0c0, 0x18f, + 0x004 +]; + +#[rustfmt::skip] +const SCF_CODEBOOK_LENS: [u8; 121] = [ + 18, 18, 18, 18, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, + 19, 19, 19, 18, 19, 18, 17, 17, 16, 17, 16, 16, 16, 16, 15, 15, + 14, 14, 14, 14, 14, 14, 13, 13, 12, 12, 12, 11, 12, 11, 10, 10, + 10, 9, 9, 8, 8, 8, 7, 6, 6, 5, 4, 3, 1, 4, 4, 5, + 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 11, 12, + 12, 13, 13, 13, 14, 14, 16, 15, 16, 15, 18, 19, 19, 19, 19, 19, + 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, + 19, 19, 19, 19, 19, 19, 19, 19, 19 +]; + +#[rustfmt::skip] +static SCF_CODEBOOK_CODES: [u32; 121] = [ + 0x3FFE8, 0x3FFE6, 0x3FFE7, 0x3FFE5, 0x7FFF5, 0x7FFF1, 0x7FFED, 0x7FFF6, + 0x7FFEE, 0x7FFEF, 0x7FFF0, 0x7FFFC, 0x7FFFD, 0x7FFFF, 0x7FFFE, 0x7FFF7, + 0x7FFF8, 0x7FFFB, 0x7FFF9, 0x3FFE4, 0x7FFFA, 0x3FFE3, 0x1FFEF, 0x1FFF0, + 0x0FFF5, 0x1FFEE, 0x0FFF2, 0x0FFF3, 0x0FFF4, 0x0FFF1, 0x07FF6, 0x07FF7, + 0x03FF9, 0x03FF5, 0x03FF7, 0x03FF3, 0x03FF6, 0x03FF2, 0x01FF7, 0x01FF5, + 0x00FF9, 0x00FF7, 0x00FF6, 0x007F9, 0x00FF4, 0x007F8, 0x003F9, 0x003F7, + 0x003F5, 0x001F8, 0x001F7, 0x000FA, 0x000F8, 0x000F6, 0x00079, 0x0003A, + 0x00038, 0x0001A, 0x0000B, 0x00004, 0x00000, 0x0000A, 0x0000C, 0x0001B, + 0x00039, 0x0003B, 0x00078, 0x0007A, 0x000F7, 0x000F9, 0x001F6, 0x001F9, + 0x003F4, 0x003F6, 0x003F8, 0x007F5, 0x007F4, 0x007F6, 0x007F7, 0x00FF5, + 0x00FF8, 0x01FF4, 0x01FF6, 0x01FF8, 0x03FF8, 0x03FF4, 0x0FFF0, 0x07FF4, + 0x0FFF6, 0x07FF5, 0x3FFE2, 0x7FFD9, 0x7FFDA, 0x7FFDB, 0x7FFDC, 0x7FFDD, + 0x7FFDE, 0x7FFD8, 0x7FFD2, 0x7FFD3, 0x7FFD4, 0x7FFD5, 0x7FFD6, 0x7FFF2, + 0x7FFDF, 0x7FFE7, 0x7FFE8, 0x7FFE9, 0x7FFEA, 0x7FFEB, 0x7FFE6, 0x7FFE0, + 0x7FFE1, 0x7FFE2, 0x7FFE3, 0x7FFE4, 0x7FFE5, 0x7FFD7, 0x7FFEC, 0x7FFF4, + 0x7FFF3 +]; + +static AAC_QUADS: [(u8, u8, u8, u8); 81] = [ + (0, 0, 0, 0), + (0, 0, 0, 1), + (0, 0, 0, 2), + (0, 0, 1, 0), + (0, 0, 1, 1), + (0, 0, 1, 2), + (0, 0, 2, 0), + (0, 0, 2, 1), + (0, 0, 2, 2), + (0, 1, 0, 0), + (0, 1, 0, 1), + (0, 1, 0, 2), + (0, 1, 1, 0), + (0, 1, 1, 1), + (0, 1, 1, 2), + (0, 1, 2, 0), + (0, 1, 2, 1), + (0, 1, 2, 2), + (0, 2, 0, 0), + (0, 2, 0, 1), + (0, 2, 0, 2), + (0, 2, 1, 0), + (0, 2, 1, 1), + (0, 2, 1, 2), + (0, 2, 2, 0), + (0, 2, 2, 1), + (0, 2, 2, 2), + (1, 0, 0, 0), + (1, 0, 0, 1), + (1, 0, 0, 2), + (1, 0, 1, 0), + (1, 0, 1, 1), + (1, 0, 1, 2), + (1, 0, 2, 0), + (1, 0, 2, 1), + (1, 0, 2, 2), + (1, 1, 0, 0), + (1, 1, 0, 1), + (1, 1, 0, 2), + (1, 1, 1, 0), + (1, 1, 1, 1), + (1, 1, 1, 2), + (1, 1, 2, 0), + (1, 1, 2, 1), + (1, 1, 2, 2), + (1, 2, 0, 0), + (1, 2, 0, 1), + (1, 2, 0, 2), + (1, 2, 1, 0), + (1, 2, 1, 1), + (1, 2, 1, 2), + (1, 2, 2, 0), + (1, 2, 2, 1), + (1, 2, 2, 2), + (2, 0, 0, 0), + (2, 0, 0, 1), + (2, 0, 0, 2), + (2, 0, 1, 0), + (2, 0, 1, 1), + (2, 0, 1, 2), + (2, 0, 2, 0), + (2, 0, 2, 1), + (2, 0, 2, 2), + (2, 1, 0, 0), + (2, 1, 0, 1), + (2, 1, 0, 2), + (2, 1, 1, 0), + (2, 1, 1, 1), + (2, 1, 1, 2), + (2, 1, 2, 0), + (2, 1, 2, 1), + (2, 1, 2, 2), + (2, 2, 0, 0), + (2, 2, 0, 1), + (2, 2, 0, 2), + (2, 2, 1, 0), + (2, 2, 1, 1), + (2, 2, 1, 2), + (2, 2, 2, 0), + (2, 2, 2, 1), + (2, 2, 2, 2), +]; + +struct VlcTable { + codes: &'static [u32], + lens: &'static [u8], +} + +static SPECTRUM_TABLES: [VlcTable; 11] = [ + VlcTable { codes: &SPECTRUM_CODEBOOK1_CODES, lens: &SPECTRUM_CODEBOOK1_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK2_CODES, lens: &SPECTRUM_CODEBOOK2_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK3_CODES, lens: &SPECTRUM_CODEBOOK3_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK4_CODES, lens: &SPECTRUM_CODEBOOK4_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK5_CODES, lens: &SPECTRUM_CODEBOOK5_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK6_CODES, lens: &SPECTRUM_CODEBOOK6_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK7_CODES, lens: &SPECTRUM_CODEBOOK7_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK8_CODES, lens: &SPECTRUM_CODEBOOK8_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK9_CODES, lens: &SPECTRUM_CODEBOOK9_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK10_CODES, lens: &SPECTRUM_CODEBOOK10_LENS }, + VlcTable { codes: &SPECTRUM_CODEBOOK11_CODES, lens: &SPECTRUM_CODEBOOK11_LENS }, +]; + +/// Make a codebook that returns the index of the read code. +trait MakeBasicCodebook { + type ValueType; + fn new(codebook: Codebook) -> Self; +} + +/// Make a codebook that returns a value. +trait MakeValueCodebook { + type ValueType; + fn new(codebook: Codebook, values: Box<[Self::ValueType]>) -> Self; +} + +/// Codebook for spectral quads. +#[derive(Default)] +pub struct QuadsCodebook { + codebook: Codebook, +} + +impl QuadsCodebook { + #[inline(always)] + pub fn read_quant(&self, bs: &mut B) -> std::io::Result<(u8, u8, u8, u8)> { + bs.read_codebook(&self.codebook).map(|(cw, _)| AAC_QUADS[cw as usize]) + } +} + +impl MakeBasicCodebook for QuadsCodebook { + type ValueType = (u8, u8, u8, u8); + + fn new(codebook: Codebook) -> Self { + Self { codebook } + } +} + +/// Codebook for spectral pairs. +#[derive(Default)] +pub struct PairsCodebook { + codebook: Codebook, + values: Box<[(f32, f32)]>, +} + +impl PairsCodebook { + #[inline(always)] + pub fn read_dequant(&self, bs: &mut B) -> std::io::Result<(f32, f32)> { + bs.read_codebook(&self.codebook).map(|(cw, _)| self.values[cw as usize]) + } +} + +impl MakeValueCodebook for PairsCodebook { + type ValueType = (f32, f32); + + fn new(codebook: Codebook, values: Box<[Self::ValueType]>) -> Self { + Self { codebook, values } + } +} + +/// Codebook for spectral pairs with an escape code. +pub struct EscapeCodebook { + codebook: Codebook, + values: Box<[(u16, u16)]>, +} + +impl EscapeCodebook { + #[inline(always)] + pub fn read_quant(&self, bs: &mut B) -> std::io::Result<(u16, u16)> { + bs.read_codebook(&self.codebook).map(|(cw, _)| self.values[cw as usize]) + } +} + +impl MakeValueCodebook for EscapeCodebook { + type ValueType = (u16, u16); + + fn new(codebook: Codebook, values: Box<[Self::ValueType]>) -> Self { + Self { codebook, values } + } +} + +/// Given an AAC table containing a list of variable length codes and the length of each code, +/// generate a codebook. +fn make_raw_codebook(table: &VlcTable) -> Codebook { + assert_eq!(table.codes.len(), table.lens.len()); + + let len = table.codes.len() as u16; + + // Generate the indicies for each code. + let indicies: Vec = (0..len).collect(); + + // Generate the codebook. + let mut builder = CodebookBuilder::new(BitOrder::Verbatim); + + // Read in 8-bit blocks. + builder.bits_per_read(8); + + builder.make(table.codes, table.lens, &indicies).expect("valid static codebook data") +} + +/// Generate a codebook, but also generate a list of values for each variable length code using +/// the function `f`. The function `f` maps the index of a code to a value. +fn make_value_codebook(table: &VlcTable, f: F) -> C +where + C: MakeValueCodebook, + F: Fn(usize) -> C::ValueType, +{ + let codebook = make_raw_codebook(table); + + // Generate values for the codebook. + let values: Vec = (0..table.codes.len()).map(f).collect(); + + C::new(codebook, values.into_boxed_slice()) +} + +/// Generate a codebook without any stored values. +fn make_basic_codebook(table: &VlcTable) -> C +where + C: MakeBasicCodebook, +{ + C::new(make_raw_codebook(table)) +} + +/// Inverse quantization. +#[inline(always)] +fn iquant(val: usize) -> f32 { + f32::powf(val as f32, 4.0 / 3.0) +} + +fn signed_pair(cw: usize) -> (f32, f32) { + let modulo_2 = MOD >> 1; + + let a = cw / MOD; + let b = cw % MOD; + + let x = if modulo_2 > a { -iquant(modulo_2 - a) } else { iquant(a - modulo_2) }; + let y = if modulo_2 > b { -iquant(modulo_2 - b) } else { iquant(b - modulo_2) }; + + (x, y) +} + +fn unsigned_pair(cw: usize) -> (f32, f32) { + (iquant(cw / MOD), iquant(cw % MOD)) +} + +fn escape_pair(cw: usize) -> (u16, u16) { + ((cw / MOD) as u16, (cw % MOD) as u16) +} + +lazy_static! { + pub static ref QUADS: [QuadsCodebook; 4] = [ + make_basic_codebook(&SPECTRUM_TABLES[0]), + make_basic_codebook(&SPECTRUM_TABLES[1]), + make_basic_codebook(&SPECTRUM_TABLES[2]), + make_basic_codebook(&SPECTRUM_TABLES[3]), + ]; +} + +lazy_static! { + pub static ref PAIRS: [PairsCodebook; 6] = [ + make_value_codebook(&SPECTRUM_TABLES[4], signed_pair::<9>), + make_value_codebook(&SPECTRUM_TABLES[5], signed_pair::<9>), + make_value_codebook(&SPECTRUM_TABLES[6], unsigned_pair::<8>), + make_value_codebook(&SPECTRUM_TABLES[7], unsigned_pair::<8>), + make_value_codebook(&SPECTRUM_TABLES[8], unsigned_pair::<13>), + make_value_codebook(&SPECTRUM_TABLES[9], unsigned_pair::<13>), + ]; +} + +lazy_static! { + pub static ref ESC: EscapeCodebook = + make_value_codebook(&SPECTRUM_TABLES[10], escape_pair::<17>); +} + +lazy_static! { + pub static ref SCALEFACTORS: Codebook = { + assert_eq!(SCF_CODEBOOK_CODES.len(), SCF_CODEBOOK_LENS.len()); + + let len = SCF_CODEBOOK_CODES.len() as u8; + + // Generate the values for the codebook. + let values: Vec = (0..len).collect(); + + // Generate the codebook. + let mut builder = CodebookBuilder::new(BitOrder::Verbatim); + + // Read in 8-bit blocks. + builder.bits_per_read(8); + + builder.make(&SCF_CODEBOOK_CODES, &SCF_CODEBOOK_LENS, &values).expect("valid static codebook data") + }; +} diff --git a/vendor/symphonia-codec-aac/src/aac/common.rs b/vendor/symphonia-codec-aac/src/aac/common.rs new file mode 100644 index 00000000..4b671e30 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/common.rs @@ -0,0 +1,196 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +pub const MAX_WINDOWS: usize = 8; +pub const MAX_SFBS: usize = 64; + +pub const ONLY_LONG_SEQUENCE: u8 = 0; +pub const LONG_START_SEQUENCE: u8 = 1; +pub const EIGHT_SHORT_SEQUENCE: u8 = 2; +pub const LONG_STOP_SEQUENCE: u8 = 3; + +pub static SWB_OFFSET_48K_LONG: [usize; 49 + 1] = [ + 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 88, 96, 108, 120, 132, 144, 160, + 176, 196, 216, 240, 264, 292, 320, 352, 384, 416, 448, 480, 512, 544, 576, 608, 640, 672, 704, + 736, 768, 800, 832, 864, 896, 928, 1024, +]; + +pub const SWB_OFFSET_48K_SHORT: [usize; 14 + 1] = + [0, 4, 8, 12, 16, 20, 28, 36, 44, 56, 68, 80, 96, 112, 128]; + +pub static SWB_OFFSET_32K_LONG: [usize; 51 + 1] = [ + 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 88, 96, 108, 120, 132, 144, 160, + 176, 196, 216, 240, 264, 292, 320, 352, 384, 416, 448, 480, 512, 544, 576, 608, 640, 672, 704, + 736, 768, 800, 832, 864, 896, 928, 960, 992, 1024, +]; + +pub static SWB_OFFSET_8K_LONG: [usize; 40 + 1] = [ + 0, 12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132, 144, 156, 172, 188, 204, 220, 236, 252, 268, + 288, 308, 328, 348, 372, 396, 420, 448, 476, 508, 544, 580, 620, 664, 712, 764, 820, 880, 944, + 1024, +]; + +pub const SWB_OFFSET_8K_SHORT: [usize; 15 + 1] = + [0, 4, 8, 12, 16, 20, 24, 28, 36, 44, 52, 60, 72, 88, 108, 128]; + +pub static SWB_OFFSET_16K_LONG: [usize; 43 + 1] = [ + 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 100, 112, 124, 136, 148, 160, 172, 184, 196, 212, + 228, 244, 260, 280, 300, 320, 344, 368, 396, 424, 456, 492, 532, 572, 616, 664, 716, 772, 832, + 896, 960, 1024, +]; + +pub const SWB_OFFSET_16K_SHORT: [usize; 15 + 1] = + [0, 4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 60, 72, 88, 108, 128]; + +pub static SWB_OFFSET_24K_LONG: [usize; 47 + 1] = [ + 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 52, 60, 68, 76, 84, 92, 100, 108, 116, 124, 136, + 148, 160, 172, 188, 204, 220, 240, 260, 284, 308, 336, 364, 396, 432, 468, 508, 552, 600, 652, + 704, 768, 832, 896, 960, 1024, +]; + +pub const SWB_OFFSET_24K_SHORT: [usize; 15 + 1] = + [0, 4, 8, 12, 16, 20, 24, 28, 36, 44, 52, 64, 76, 92, 108, 128]; + +pub static SWB_OFFSET_64K_LONG: [usize; 47 + 1] = [ + 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 64, 72, 80, 88, 100, 112, 124, 140, + 156, 172, 192, 216, 240, 268, 304, 344, 384, 424, 464, 504, 544, 584, 624, 664, 704, 744, 784, + 824, 864, 904, 944, 984, 1024, +]; + +pub const SWB_OFFSET_64K_SHORT: [usize; 12 + 1] = + [0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 92, 128]; + +pub static SWB_OFFSET_96K_LONG: [usize; 41 + 1] = [ + 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 64, 72, 80, 88, 96, 108, 120, 132, + 144, 156, 172, 188, 212, 240, 276, 320, 384, 448, 512, 576, 640, 704, 768, 832, 896, 960, 1024, +]; + +/// A Linear Congruential Generator (LCG) pseudo-random number generator from Numerical Recipes. +#[derive(Clone)] +pub struct Lcg { + state: u32, +} + +impl Lcg { + pub fn new(state: u32) -> Self { + Lcg { state } + } + + #[inline(always)] + pub fn next(&mut self) -> i32 { + // Numerical Recipes LCG parameters. + self.state = self.state.wrapping_mul(1664525).wrapping_add(1013904223); + self.state as i32 + } +} + +#[derive(Clone, Copy)] +pub struct GASubbandInfo { + pub min_srate: u32, + pub long_bands: &'static [usize], + pub short_bands: &'static [usize], +} + +impl GASubbandInfo { + pub fn find(srate: u32) -> GASubbandInfo { + for sbi in AAC_SUBBAND_INFO.iter() { + if srate >= sbi.min_srate { + return *sbi; + } + } + unreachable!() + } + + pub fn find_idx(srate: u32) -> usize { + for (i, sbi) in AAC_SUBBAND_INFO.iter().enumerate() { + if srate >= sbi.min_srate { + return i; + } + } + unreachable!() + } +} + +static AAC_SUBBAND_INFO: [GASubbandInfo; 12] = [ + GASubbandInfo { + min_srate: 92017, + long_bands: &SWB_OFFSET_96K_LONG, + short_bands: &SWB_OFFSET_64K_SHORT, + }, //96K + GASubbandInfo { + min_srate: 75132, + long_bands: &SWB_OFFSET_96K_LONG, + short_bands: &SWB_OFFSET_64K_SHORT, + }, //88.2K + GASubbandInfo { + min_srate: 55426, + long_bands: &SWB_OFFSET_64K_LONG, + short_bands: &SWB_OFFSET_64K_SHORT, + }, //64K + GASubbandInfo { + min_srate: 46009, + long_bands: &SWB_OFFSET_48K_LONG, + short_bands: &SWB_OFFSET_48K_SHORT, + }, //48K + GASubbandInfo { + min_srate: 37566, + long_bands: &SWB_OFFSET_48K_LONG, + short_bands: &SWB_OFFSET_48K_SHORT, + }, //44.1K + GASubbandInfo { + min_srate: 27713, + long_bands: &SWB_OFFSET_32K_LONG, + short_bands: &SWB_OFFSET_48K_SHORT, + }, //32K + GASubbandInfo { + min_srate: 23004, + long_bands: &SWB_OFFSET_24K_LONG, + short_bands: &SWB_OFFSET_24K_SHORT, + }, //24K + GASubbandInfo { + min_srate: 18783, + long_bands: &SWB_OFFSET_24K_LONG, + short_bands: &SWB_OFFSET_24K_SHORT, + }, //22.05K + GASubbandInfo { + min_srate: 13856, + long_bands: &SWB_OFFSET_16K_LONG, + short_bands: &SWB_OFFSET_16K_SHORT, + }, //16K + GASubbandInfo { + min_srate: 11502, + long_bands: &SWB_OFFSET_16K_LONG, + short_bands: &SWB_OFFSET_16K_SHORT, + }, //12K + GASubbandInfo { + min_srate: 9391, + long_bands: &SWB_OFFSET_16K_LONG, + short_bands: &SWB_OFFSET_16K_SHORT, + }, //11.025K + GASubbandInfo { + min_srate: 0, + long_bands: &SWB_OFFSET_8K_LONG, + short_bands: &SWB_OFFSET_8K_SHORT, + }, //8K +]; + +macro_rules! validate { + ($a:expr) => { + if !$a { + log::error!("check failed at {}:{}", file!(), line!()); + return symphonia_core::errors::decode_error("aac: invalid data"); + } + }; +} + +pub(crate) use validate; diff --git a/vendor/symphonia-codec-aac/src/aac/cpe.rs b/vendor/symphonia-codec-aac/src/aac/cpe.rs new file mode 100644 index 00000000..95a44f89 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/cpe.rs @@ -0,0 +1,182 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::audio::{AudioBuffer, AudioMut}; +use symphonia_core::errors::{Result, decode_error}; +use symphonia_core::io::ReadBitsLtr; + +use symphonia_common::mpeg::audio::AudioObjectType; + +use crate::aac::common::*; +use crate::aac::dsp; +use crate::aac::ics; + +#[derive(Clone)] +pub struct ChannelPair { + pub is_pair: bool, + pub channel: usize, + ms_mask_present: u8, + ms_used: [[bool; MAX_SFBS]; MAX_WINDOWS], + ics0: ics::Ics, + ics1: ics::Ics, + lcg: Lcg, +} + +impl ChannelPair { + pub fn new(is_pair: bool, channel: usize, sbinfo: GASubbandInfo) -> Self { + Self { + is_pair, + channel, + ms_mask_present: 0, + ms_used: [[false; MAX_SFBS]; MAX_WINDOWS], + ics0: ics::Ics::new(sbinfo), + ics1: ics::Ics::new(sbinfo), + lcg: Lcg::new(0x1f2e3d4c), // Use the same seed as ffmpeg for symphonia-check. + } + } + + pub fn reset(&mut self) { + self.ics0.reset(); + self.ics1.reset(); + } + + pub fn decode_ga_sce( + &mut self, + bs: &mut B, + aot: AudioObjectType, + ) -> Result<()> { + self.ics0.decode(bs, &mut self.lcg, aot, false)?; + Ok(()) + } + + pub fn decode_ga_cpe( + &mut self, + bs: &mut B, + aot: AudioObjectType, + ) -> Result<()> { + let common_window = bs.read_bool()?; + + if common_window { + // Decode the common ICS info block into the first channel. + // do not call self.ics0.info.decode() as it will skip required validations present in self.ics0.decode_info() + self.ics0.decode_info(bs)?; + + // Mid-side stereo mask decoding. + self.ms_mask_present = bs.read_bits_leq32(2)? as u8; + + match self.ms_mask_present { + 0 | 2 => { + // If mid-side mask present is 0, then mid-side coding is never used. If the + // value is 2, then mid-side coding is always used. + let is_used = self.ms_mask_present == 2; + + for g in 0..self.ics0.info.window_groups { + for sfb in 0..self.ics0.info.max_sfb { + self.ms_used[g][sfb] = is_used; + } + } + } + 1 => { + // If mid-side mask present is 1, then read a bit for each band indicating if + // the band uses mid-side coding. + for g in 0..self.ics0.info.window_groups { + for sfb in 0..self.ics0.info.max_sfb { + self.ms_used[g][sfb] = bs.read_bool()?; + } + } + } + 3 => return decode_error("aac: invalid mid-side mask"), + _ => unreachable!(), + } + + // Copy the common ICS info decoded in the first channel to the second channel. + self.ics1.info.copy_from_common(&self.ics0.info); + } + + self.ics0.decode(bs, &mut self.lcg, aot, common_window)?; + self.ics1.decode(bs, &mut self.lcg, aot, common_window)?; + + // Joint-stereo decoding + if common_window { + let bands = self.ics0.get_bands(); + + let mut g = 0; + + for w in 0..self.ics0.info.num_windows { + if w > 0 && !self.ics0.info.scale_factor_grouping[w - 1] { + g += 1; + } + + for sfb in 0..self.ics0.info.max_sfb { + let start = w * 128 + bands[sfb]; + let end = w * 128 + bands[sfb + 1]; + + if self.ics1.is_intensity(g, sfb) { + // Intensity stereo + // Section 4.6.8.2.3 + let invert = self.ms_mask_present == 1 && self.ms_used[g][sfb]; + let dir = if self.ics1.get_intensity_dir(g, sfb) { 1.0 } else { -1.0 }; + let factor = if invert { -1.0 } else { 1.0 }; + + let scale = dir * factor * self.ics1.scales[g][sfb]; + + let left = &self.ics0.coeffs[start..end]; + let right = &mut self.ics1.coeffs[start..end]; + + for (l, r) in left.iter().zip(right) { + *r = scale * l; + } + } + else if self.ics0.is_noise(g, sfb) || self.ics1.is_noise(g, sfb) { + // Perceptual noise substitution, do not do joint-stereo decoding. + // Section 4.6.13.3 + } + else if self.ms_used[g][sfb] { + // Mid-side stereo. + let mid = &mut self.ics0.coeffs[start..end]; + let side = &mut self.ics1.coeffs[start..end]; + + for (m, s) in mid.iter_mut().zip(side) { + let tmp = *m - *s; + *m += *s; + *s = tmp; + } + } + } + } + } + + Ok(()) + } + + pub fn synth_audio( + &mut self, + dsp: &mut dsp::Dsp, + abuf: &mut AudioBuffer, + rate_idx: usize, + ) { + self.ics0.synth_channel( + dsp, + rate_idx, + abuf.plane_mut(self.channel).expect("channel index is valid"), + ); + + if self.is_pair { + self.ics1.synth_channel( + dsp, + rate_idx, + abuf.plane_mut(self.channel + 1).expect("channel+1 index is valid"), + ); + } + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/dsp.rs b/vendor/symphonia-codec-aac/src/aac/dsp.rs new file mode 100644 index 00000000..7297fa47 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/dsp.rs @@ -0,0 +1,159 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::dsp::mdct::Imdct; + +use crate::aac::common::*; +use crate::aac::window::*; + +const SHORT_WIN_POINT0: usize = 512 - 64; +const SHORT_WIN_POINT1: usize = 512 + 64; + +pub struct Dsp { + kbd_long_win: [f32; 1024], + kbd_short_win: [f32; 128], + sine_long_win: [f32; 1024], + sine_short_win: [f32; 128], + imdct_long: Imdct, + imdct_short: Imdct, + pcm_long: [f32; 2048], + pcm_short: [f32; 1152], +} + +impl Dsp { + pub fn new() -> Self { + let mut kbd_long_win: [f32; 1024] = [0.0; 1024]; + let mut kbd_short_win: [f32; 128] = [0.0; 128]; + generate_window(WindowType::KaiserBessel(4.0), 1.0, 1024, true, &mut kbd_long_win); + generate_window(WindowType::KaiserBessel(6.0), 1.0, 128, true, &mut kbd_short_win); + let mut sine_long_win: [f32; 1024] = [0.0; 1024]; + let mut sine_short_win: [f32; 128] = [0.0; 128]; + generate_window(WindowType::Sine, 1.0, 1024, true, &mut sine_long_win); + generate_window(WindowType::Sine, 1.0, 128, true, &mut sine_short_win); + + Self { + kbd_long_win, + kbd_short_win, + sine_long_win, + sine_short_win, + imdct_long: Imdct::new_scaled(1024, 1.0 / 2048.0), + imdct_short: Imdct::new_scaled(128, 1.0 / 256.0), + pcm_long: [0.0; 2048], + pcm_short: [0.0; 1152], + } + } + + #[allow(clippy::cognitive_complexity)] + pub fn synth( + &mut self, + coeffs: &[f32; 1024], + delay: &mut [f32; 1024], + seq: u8, + window_shape: bool, + prev_window_shape: bool, + dst: &mut [f32], + ) { + let (long_win, short_win) = match window_shape { + true => (&self.kbd_long_win, &self.kbd_short_win), + false => (&self.sine_long_win, &self.sine_short_win), + }; + + let (prev_long_win, prev_short_win) = match prev_window_shape { + true => (&self.kbd_long_win, &self.kbd_short_win), + false => (&self.sine_long_win, &self.sine_short_win), + }; + + // Inverse MDCT + if seq != EIGHT_SHORT_SEQUENCE { + self.imdct_long.imdct(coeffs, &mut self.pcm_long); + } + else { + for (ain, aout) in coeffs.chunks_exact(128).zip(self.pcm_long.chunks_exact_mut(256)) { + self.imdct_short.imdct(ain, aout); + } + + // Zero the eight short sequence buffer. + self.pcm_short.fill(0.0); + + for (w, src) in self.pcm_long.chunks_exact(256).enumerate() { + if w > 0 { + for i in 0..128 { + self.pcm_short[w * 128 + i] += src[i] * short_win[i]; + self.pcm_short[w * 128 + i + 128] += src[i + 128] * short_win[127 - i]; + } + } + else { + for i in 0..128 { + self.pcm_short[i] = src[i] * prev_short_win[i]; + self.pcm_short[i + 128] = src[i + 128] * short_win[127 - i]; + } + } + } + } + + // Output new audio samples. + match seq { + ONLY_LONG_SEQUENCE | LONG_START_SEQUENCE => { + for i in 0..1024 { + dst[i] = delay[i] + (self.pcm_long[i] * prev_long_win[i]); + } + } + EIGHT_SHORT_SEQUENCE => { + dst[..SHORT_WIN_POINT0].copy_from_slice(&delay[..SHORT_WIN_POINT0]); + + for i in SHORT_WIN_POINT0..1024 { + dst[i] = delay[i] + self.pcm_short[i - SHORT_WIN_POINT0]; + } + } + LONG_STOP_SEQUENCE => { + dst[..SHORT_WIN_POINT0].copy_from_slice(&delay[..SHORT_WIN_POINT0]); + + for i in SHORT_WIN_POINT0..SHORT_WIN_POINT1 { + dst[i] = delay[i] + self.pcm_long[i] * prev_short_win[i - SHORT_WIN_POINT0]; + } + for i in SHORT_WIN_POINT1..1024 { + dst[i] = delay[i] + self.pcm_long[i]; + } + } + _ => unreachable!(), + }; + + // Save delay for overlap. + match seq { + ONLY_LONG_SEQUENCE | LONG_STOP_SEQUENCE => { + for i in 0..1024 { + delay[i] = self.pcm_long[i + 1024] * long_win[1023 - i]; + } + } + EIGHT_SHORT_SEQUENCE => { + for i in 0..SHORT_WIN_POINT1 { + // Last part is already windowed. + delay[i] = self.pcm_short[i + 512 + 64]; + } + + delay[SHORT_WIN_POINT1..].fill(0.0); + } + LONG_START_SEQUENCE => { + delay[..SHORT_WIN_POINT0] + .copy_from_slice(&self.pcm_long[1024..(SHORT_WIN_POINT0 + 1024)]); + + for i in SHORT_WIN_POINT0..SHORT_WIN_POINT1 { + delay[i] = self.pcm_long[i + 1024] * short_win[127 - (i - SHORT_WIN_POINT0)]; + } + + delay[SHORT_WIN_POINT1..].fill(0.0); + } + _ => unreachable!(), + }; + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/ics/gain.rs b/vendor/symphonia-codec-aac/src/aac/ics/gain.rs new file mode 100644 index 00000000..1438af1c --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/ics/gain.rs @@ -0,0 +1,42 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::{Result, unsupported_error}; +use symphonia_core::io::ReadBitsLtr; + +#[derive(Clone, Copy)] +#[allow(dead_code)] +pub struct GainControl { + max_band: u8, +} + +impl GainControl { + pub fn read(bs: &mut B) -> Result> { + let gain_control_data_present = bs.read_bool()?; + + if !gain_control_data_present { + return Ok(None); + } + + /* + self.max_band = bs.read_bits_leq32(2)? as u8; + if window_sequence == ONLY_LONG_SEQUENCE { + for bd in 0..max_band + ... + } + Ok(Some(Self { })) + */ + + unsupported_error("aac: gain control data") + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/ics/ltp.rs b/vendor/symphonia-codec-aac/src/aac/ics/ltp.rs new file mode 100644 index 00000000..f6fbd21d --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/ics/ltp.rs @@ -0,0 +1,55 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::{Result, unsupported_error}; +use symphonia_core::io::ReadBitsLtr; + +#[derive(Clone, Copy)] +pub struct LtpData {} + +impl LtpData { + pub fn read(bs: &mut B) -> Result> { + let predictor_data_present = bs.read_bool()?; + + if !predictor_data_present { + return Ok(None); + } + + /* + if is_main { + let predictor_reset = bs.read_bit()?; + if predictor_reset { + let predictor_reset_group_number = bs.read_bits_leq32(5)?; + } + for sfb in 0..max_sfb.min(PRED_SFB_MAX) { + prediction_used[sfb] = bs.read_bit()?; + } + } + else { + let ltp_data_present = bs.read_bit()?; + if ltp_data_present { + //ltp data + } + if common_window { + let ltp_data_present = bs.read_bit()?; + if ltp_data_present { + //ltp data + } + } + } + Ok(Some(Self { })) + */ + + unsupported_error("aac: predictor data") + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/ics/mod.rs b/vendor/symphonia-codec-aac/src/aac/ics/mod.rs new file mode 100644 index 00000000..449dbbe7 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/ics/mod.rs @@ -0,0 +1,636 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::{Result, decode_error}; +use symphonia_core::io::ReadBitsLtr; +use symphonia_core::io::vlc::{Codebook, Entry8x16}; + +use symphonia_common::mpeg::audio::AudioObjectType; + +use crate::aac::codebooks; +use crate::aac::common::*; +use crate::aac::dsp; + +use lazy_static::lazy_static; +use log::debug; + +mod gain; +mod ltp; +mod pulse; +mod tns; + +const ZERO_HCB: u8 = 0; +const RESERVED_HCB: u8 = 12; +const NOISE_HCB: u8 = 13; +const INTENSITY_HCB2: u8 = 14; +const INTENSITY_HCB: u8 = 15; + +const INTENSITY_SCALE_MIN: i16 = -155; +const NORMAL_SCALE_MIN: i16 = -100; + +/// The length of the `POW43` table. +const POW43_TABLE_LEN: usize = 8192; + +lazy_static! { + /// Pre-computed table of y = x^(4/3). + static ref POW43_TABLE: Box<[f32; POW43_TABLE_LEN]> = { + let table: Vec = (0..POW43_TABLE_LEN).map(|i| (i as f32).powf(4.0 / 3.0)).collect(); + table.into_boxed_slice().try_into().expect("vec initialized to correct table length") + }; +} + +/// The length of the `NORMAL_SCF_TABLE` table. +const NORMAL_SCF_TABLE_LEN: usize = 256; + +lazy_static! { + /// Pre-computed table of y = 2^(0.25 * (x - 156)) for decoding scale factors for normal bands. + /// This table is indexed relative to -100, the minimum encoded scale factor value for normal + /// bands. Therefore, an input of 0 corresponds to -100. + static ref NORMAL_SCF_TABLE: Box<[f32; NORMAL_SCF_TABLE_LEN]> = { + let table: Vec = (0..NORMAL_SCF_TABLE_LEN) + .map(|i| 2.0f32.powf(0.25 * f32::from(i as i16 - 56 + NORMAL_SCALE_MIN))) + .collect(); + + table.into_boxed_slice().try_into().expect("vec initialized to correct table length") + }; +} + +/// The length of the `INTENSITY_SCF_TABLE` table. +const INTENSITY_SCF_TABLE_LEN: usize = 256; + +lazy_static! { + /// Pre-computed table of y = 0.5^(0.25 * (x - 155)) for decoding scale factors for intensity + /// coded bands. This table is indexed relative to -155, the minimum encoded scale factor value + /// for intensity coded bands. Therefore, an input of 0 corresponds to -155. + static ref INTENSITY_SCF_TABLE: Box<[f32; INTENSITY_SCF_TABLE_LEN]> = { + let table: Vec = (0..INTENSITY_SCF_TABLE_LEN) + .map(|i| 0.5f32.powf(0.25 * f32::from(i as i16 + INTENSITY_SCALE_MIN))) + .collect(); + + table.into_boxed_slice().try_into().expect("vec initialized to correct table length") + }; +} + +#[derive(Clone)] +pub struct IcsInfo { + pub window_sequence: u8, + pub prev_window_sequence: u8, + pub window_shape: bool, + pub prev_window_shape: bool, + pub scale_factor_grouping: [bool; MAX_WINDOWS], + pub group_start: [usize; MAX_WINDOWS], + pub window_groups: usize, + pub num_windows: usize, + pub max_sfb: usize, + pub long_win: bool, + pub ltp: Option, +} + +impl IcsInfo { + fn new() -> Self { + Self { + window_sequence: 0, + prev_window_sequence: 0, + window_shape: false, + prev_window_shape: false, + scale_factor_grouping: [false; MAX_WINDOWS], + group_start: [0; MAX_WINDOWS], + num_windows: 0, + window_groups: 0, + max_sfb: 0, + ltp: None, + long_win: true, + } + } + + /// this method should be called from Ics::decode_info() which will perform additional validations for max_sfb + pub fn decode(&mut self, bs: &mut B) -> Result<()> { + self.prev_window_sequence = self.window_sequence; + self.prev_window_shape = self.window_shape; + + if bs.read_bool()? { + return decode_error("aac: ics reserved bit set"); + } + + self.window_sequence = bs.read_bits_leq32(2)? as u8; + + match self.prev_window_sequence { + ONLY_LONG_SEQUENCE | LONG_STOP_SEQUENCE + if (self.window_sequence != ONLY_LONG_SEQUENCE) + && (self.window_sequence != LONG_START_SEQUENCE) => + { + debug!("previous window is invalid"); + } + LONG_START_SEQUENCE | EIGHT_SHORT_SEQUENCE + if (self.window_sequence != EIGHT_SHORT_SEQUENCE) + && (self.window_sequence != LONG_STOP_SEQUENCE) => + { + debug!("previous window is invalid"); + } + _ => (), + }; + + self.window_shape = bs.read_bool()?; + self.window_groups = 1; + + if self.window_sequence == EIGHT_SHORT_SEQUENCE { + self.long_win = false; + self.num_windows = 8; + self.max_sfb = bs.read_bits_leq32(4)? as usize; + + for i in 0..MAX_WINDOWS - 1 { + self.scale_factor_grouping[i] = bs.read_bool()?; + + if !self.scale_factor_grouping[i] { + self.group_start[self.window_groups] = i + 1; + self.window_groups += 1; + } + } + } + else { + self.long_win = true; + self.num_windows = 1; + self.max_sfb = bs.read_bits_leq32(6)? as usize; + self.ltp = ltp::LtpData::read(bs)?; + } + Ok(()) + } + + pub fn copy_from_common(&mut self, other: &IcsInfo) { + // Maintain the previous window sequence and shape. + let prev_window_sequence = self.window_sequence; + let prev_window_shape = self.window_shape; + + *self = other.clone(); + + self.prev_window_sequence = prev_window_sequence; + self.prev_window_shape = prev_window_shape; + } + + fn get_group_start(&self, g: usize) -> usize { + if g == 0 { + 0 + } + else if g >= self.window_groups { + if self.long_win { 1 } else { 8 } + } + else { + self.group_start[g] + } + } +} + +#[derive(Clone)] +pub struct Ics { + global_gain: u8, + pub info: IcsInfo, + pulse: Option, + tns: Option, + gain: Option, + sect_cb: [[u8; MAX_SFBS]; MAX_WINDOWS], + sect_len: [[usize; MAX_SFBS]; MAX_WINDOWS], + sfb_cb: [[u8; MAX_SFBS]; MAX_WINDOWS], + num_sec: [usize; MAX_WINDOWS], + pub scales: [[f32; MAX_SFBS]; MAX_WINDOWS], + sbinfo: GASubbandInfo, + pub coeffs: [f32; 1024], + delay: [f32; 1024], +} + +impl Ics { + pub fn new(sbinfo: GASubbandInfo) -> Self { + Self { + global_gain: 0, + info: IcsInfo::new(), + pulse: None, + tns: None, + gain: None, + sect_cb: [[0; MAX_SFBS]; MAX_WINDOWS], + sect_len: [[0; MAX_SFBS]; MAX_WINDOWS], + sfb_cb: [[0; MAX_SFBS]; MAX_WINDOWS], + scales: [[0.0; MAX_SFBS]; MAX_WINDOWS], + num_sec: [0; MAX_WINDOWS], + sbinfo, + coeffs: [0.0; 1024], + delay: [0.0; 1024], + } + } + + pub fn reset(&mut self) { + self.info = IcsInfo::new(); + self.delay = [0.0; 1024]; + } + + fn decode_section_data(&mut self, bs: &mut B) -> Result<()> { + let sect_bits = if self.info.long_win { 5 } else { 3 }; + let sect_esc_val = (1 << sect_bits) - 1; + + for g in 0..self.info.window_groups { + let mut k = 0; + let mut l = 0; + + while k < self.info.max_sfb { + // `l` counts sections, not bands, and advances once per section. A crafted + // stream with many zero-length sections can push it past the section arrays + // even though `k` (the band index) stays below `max_sfb`, so bound it here. + validate!(l < MAX_SFBS); + + self.sect_cb[g][l] = bs.read_bits_leq32(4)? as u8; + self.sect_len[g][l] = 0; + + if self.sect_cb[g][l] == RESERVED_HCB { + return decode_error("aac: invalid band type"); + } + + loop { + let sect_len_incr = bs.read_bits_leq32(sect_bits)? as usize; + + self.sect_len[g][l] += sect_len_incr; + + if sect_len_incr < sect_esc_val { + break; + } + } + + validate!(k + self.sect_len[g][l] <= self.info.max_sfb); + + for sfb in k..k + self.sect_len[g][l] { + self.sfb_cb[g][sfb] = self.sect_cb[g][l]; + } + + k += self.sect_len[g][l]; + l += 1; + } + + self.num_sec[g] = l; + } + Ok(()) + } + + #[inline(always)] + pub fn is_zero(&self, g: usize, sfb: usize) -> bool { + self.sfb_cb[g][sfb] == ZERO_HCB + } + + #[inline(always)] + pub fn is_intensity(&self, g: usize, sfb: usize) -> bool { + (self.sfb_cb[g][sfb] == INTENSITY_HCB) || (self.sfb_cb[g][sfb] == INTENSITY_HCB2) + } + + #[inline(always)] + pub fn is_noise(&self, g: usize, sfb: usize) -> bool { + self.sfb_cb[g][sfb] == NOISE_HCB + } + + #[inline(always)] + pub fn get_intensity_dir(&self, g: usize, sfb: usize) -> bool { + self.sfb_cb[g][sfb] == INTENSITY_HCB + } + + pub fn decode_info(&mut self, bs: &mut B) -> Result<()> { + self.info.decode(bs)?; + + // validate info.max_sfb - it should not be bigger than bands array len - 1 + if self.info.max_sfb + 1 > self.get_bands().len() { + return decode_error("aac: ics info max_sfb is too big for the bands size"); + } + Ok(()) + } + + fn decode_scale_factor_data(&mut self, bs: &mut B) -> Result<()> { + let mut noise_pcm_flag = true; + let mut scf_intensity = -INTENSITY_SCALE_MIN; + let mut scf_noise = i16::from(self.global_gain) - 90 - NORMAL_SCALE_MIN; + let mut scf_normal = i16::from(self.global_gain); + + let scf_cb: &Codebook = &codebooks::SCALEFACTORS; + + let table_normal_scf: &[f32; NORMAL_SCF_TABLE_LEN] = &NORMAL_SCF_TABLE; + let table_intensity_scf: &[f32; 256] = &INTENSITY_SCF_TABLE; + + for g in 0..self.info.window_groups { + for sfb in 0..self.info.max_sfb { + self.scales[g][sfb] = if self.is_zero(g, sfb) { + 0.0 + } + else if self.is_intensity(g, sfb) { + scf_intensity += i16::from(bs.read_codebook(scf_cb)?.0) - 60; + + // Valid range is -155 to 100. Value offset by 155 for lookup table indexing. + validate!((scf_intensity >= 0) && (scf_intensity < 256)); + + table_intensity_scf[scf_intensity as usize] + } + else if self.is_noise(g, sfb) { + if noise_pcm_flag { + noise_pcm_flag = false; + scf_noise += (bs.read_bits_leq32(9)? as i16) - 256; + } + else { + scf_noise += i16::from(bs.read_codebook(scf_cb)?.0) - 60; + } + + // Valid range is -100 to 155. Value offset by 100 for lookup table indexing. + validate!((scf_noise >= 0) && (scf_noise < 256)); + + table_normal_scf[scf_noise as usize] + } + else { + scf_normal += i16::from(bs.read_codebook(scf_cb)?.0) - 60; + + // Valid range is -100 to 155. Value offset by 100 for lookup table indexing. + validate!((scf_normal >= 0) && (scf_normal < 256)); + + table_normal_scf[scf_normal as usize] + } + } + } + Ok(()) + } + + pub fn get_bands(&self) -> &'static [usize] { + if self.info.long_win { self.sbinfo.long_bands } else { self.sbinfo.short_bands } + } + + fn decode_spectrum(&mut self, bs: &mut B, lcg: &mut Lcg) -> Result<()> { + // Zero all spectral coefficients. + self.coeffs.fill(0.0); + + let bands = self.get_bands(); + + for g in 0..self.info.window_groups { + let cur_w = self.info.get_group_start(g); + let next_w = self.info.get_group_start(g + 1); + for sfb in 0..self.info.max_sfb { + let start = bands[sfb]; + let end = bands[sfb + 1]; + + let cb_idx = self.sfb_cb[g][sfb]; + let scale = self.scales[g][sfb]; + + for w in cur_w..next_w { + let dst = &mut self.coeffs[start + w * 128..end + w * 128]; + + // Derived from ISO/IEC-14496-3 Table 4.151. + match cb_idx { + ZERO_HCB => (), + RESERVED_HCB => (), + NOISE_HCB => decode_noise(lcg, scale, dst), + INTENSITY_HCB2 => (), + INTENSITY_HCB => (), + 1 => decode_quads_signed(bs, &codebooks::QUADS[0], scale, dst)?, + 2 => decode_quads_signed(bs, &codebooks::QUADS[1], scale, dst)?, + 3 => decode_quads_unsigned(bs, &codebooks::QUADS[2], scale, dst)?, + 4 => decode_quads_unsigned(bs, &codebooks::QUADS[3], scale, dst)?, + 5 => decode_pairs_signed(bs, &codebooks::PAIRS[0], scale, dst)?, + 6 => decode_pairs_signed(bs, &codebooks::PAIRS[1], scale, dst)?, + 7 => decode_pairs_unsigned(bs, &codebooks::PAIRS[2], scale, dst)?, + 8 => decode_pairs_unsigned(bs, &codebooks::PAIRS[3], scale, dst)?, + 9 => decode_pairs_unsigned(bs, &codebooks::PAIRS[4], scale, dst)?, + 10 => decode_pairs_unsigned(bs, &codebooks::PAIRS[5], scale, dst)?, + 11 => decode_pairs_unsigned_escape(bs, &codebooks::ESC, scale, dst)?, + _ => unreachable!(), + } + } + } + } + Ok(()) + } + + pub fn decode( + &mut self, + bs: &mut B, + lcg: &mut Lcg, + aot: AudioObjectType, + common_window: bool, + ) -> Result<()> { + self.global_gain = bs.read_bits_leq32(8)? as u8; + + // If a common window is used, a common ICS info was decoded previously. + if !common_window { + // do not call self.info.decode() as it will skip required validations present in the decode_info() + self.decode_info(bs)?; + } + + self.decode_section_data(bs)?; + + self.decode_scale_factor_data(bs)?; + + self.pulse = pulse::Pulse::read(bs)?; + + validate!(self.pulse.is_none() || self.info.long_win); + + let is_aac_lc = aot == AudioObjectType::Lc; + + self.tns = tns::Tns::read(bs, &self.info, is_aac_lc)?; + + match aot { + AudioObjectType::Ssr => self.gain = gain::GainControl::read(bs)?, + _ => { + let gain_control_data_present = bs.read_bool()?; + validate!(!gain_control_data_present); + } + } + + self.decode_spectrum(bs, lcg)?; + Ok(()) + } + + pub fn synth_channel(&mut self, dsp: &mut dsp::Dsp, rate_idx: usize, dst: &mut [f32]) { + let bands = self.get_bands(); + + if let Some(pulse) = &self.pulse { + pulse.synth(bands, &self.scales, &mut self.coeffs); + } + + if let Some(tns) = &self.tns { + tns.synth(&self.info, bands, rate_idx, &mut self.coeffs); + } + + dsp.synth( + &self.coeffs, + &mut self.delay, + self.info.window_sequence, + self.info.window_shape, + self.info.prev_window_shape, + dst, + ); + } +} + +/// Perceptual noise substitution decode step. Section 4.6.13.3. +fn decode_noise(lcg: &mut Lcg, sf: f32, dst: &mut [f32]) { + let mut energy = 0.0; + + for spec in dst.iter_mut() { + // The random number generator outputs i32, but the largest signed + // integer that can convert to f32 is i16. + *spec = f32::from((lcg.next() >> 16) as i16); + energy += *spec * *spec; + } + + let scale = sf / energy.sqrt(); + + for spec in dst.iter_mut() { + *spec *= scale; + } +} + +#[inline(always)] +fn decode_sign(val: u32) -> f32 { + 1.0 - 2.0 * val as f32 +} + +fn decode_quads_unsigned( + bs: &mut B, + cb: &codebooks::QuadsCodebook, + scale: f32, + dst: &mut [f32], +) -> Result<()> { + // Table of dequantized samples for all possible quantized values. + let iquant = [0.0, scale, 2.51984209978974632953 * scale]; + + for out in dst.chunks_exact_mut(4) { + let (a, b, c, d) = cb.read_quant(bs)?; + + if a != 0 { + out[0] = decode_sign(bs.read_bit()?) * iquant[a as usize]; + } + if b != 0 { + out[1] = decode_sign(bs.read_bit()?) * iquant[b as usize]; + } + if c != 0 { + out[2] = decode_sign(bs.read_bit()?) * iquant[c as usize]; + } + if d != 0 { + out[3] = decode_sign(bs.read_bit()?) * iquant[d as usize]; + } + } + + Ok(()) +} + +fn decode_quads_signed( + bs: &mut B, + cb: &codebooks::QuadsCodebook, + scale: f32, + dst: &mut [f32], +) -> Result<()> { + // Table of dequantized samples for all possible quantized values. + let iquant = [-scale, 0.0, scale]; + + for out in dst.chunks_exact_mut(4) { + let (a, b, c, d) = cb.read_quant(bs)?; + + out[0] = iquant[a as usize]; + out[1] = iquant[b as usize]; + out[2] = iquant[c as usize]; + out[3] = iquant[d as usize]; + } + Ok(()) +} + +fn decode_pairs_signed( + bs: &mut B, + cb: &codebooks::PairsCodebook, + scale: f32, + dst: &mut [f32], +) -> Result<()> { + for out in dst.chunks_exact_mut(2) { + let (x, y) = cb.read_dequant(bs)?; + + out[0] = x * scale; + out[1] = y * scale; + } + Ok(()) +} + +fn decode_pairs_unsigned( + bs: &mut B, + cb: &codebooks::PairsCodebook, + scale: f32, + dst: &mut [f32], +) -> Result<()> { + for out in dst.chunks_exact_mut(2) { + let (x, y) = cb.read_dequant(bs)?; + + let sign_x = if x != 0.0 { decode_sign(bs.read_bit()?) } else { 1.0 }; + let sign_y = if y != 0.0 { decode_sign(bs.read_bit()?) } else { 1.0 }; + + out[0] = sign_x * x * scale; + out[1] = sign_y * y * scale; + } + + Ok(()) +} + +fn decode_pairs_unsigned_escape( + bs: &mut B, + cb: &codebooks::EscapeCodebook, + scale: f32, + dst: &mut [f32], +) -> Result<()> { + let iquant: &[f32; POW43_TABLE_LEN] = &POW43_TABLE; + + for out in dst.chunks_exact_mut(2) { + let (a, b) = cb.read_quant(bs)?; + + // Read the signs of the dequantized samples. + let sign_x = if a != 0 { decode_sign(bs.read_bit()?) } else { 1.0 }; + let sign_y = if b != 0 { decode_sign(bs.read_bit()?) } else { 1.0 }; + + let x = iquant[if a == 16 { read_escape(bs)? } else { a } as usize]; + let y = iquant[if b == 16 { read_escape(bs)? } else { b } as usize]; + + out[0] = sign_x * x * scale; + out[1] = sign_y * y * scale; + } + Ok(()) +} + +fn read_escape(bs: &mut B) -> Result { + let n = bs.read_unary_ones()?; + + validate!(n < 9); + + // The escape word is added to 2^(n + 4) to yield the unsigned value. + let word = (1 << (n + 4)) + bs.read_bits_leq32(n + 4)? as u16; + + Ok(word) +} + +#[cfg(test)] +mod tests { + use super::*; + use symphonia_core::io::BitReaderLtr; + + #[test] + fn decode_section_data_rejects_excess_zero_length_sections() { + // For a long window each section is a 4-bit codebook followed by 5-bit + // length increments. An all-zero bitstream therefore reads codebook 0 + // and a zero-length section every time, so the band index never reaches + // `max_sfb` while the section index keeps climbing. More than MAX_SFBS + // such sections used to run off the end of `sect_cb`; parsing must now + // fail cleanly instead of panicking. (MAX_SFBS + 1) sections at 9 bits + // each fit comfortably in this buffer. + let buf = [0u8; (MAX_SFBS + 1) * 9 / 8 + 1]; + + let mut ics = Ics::new(GASubbandInfo::find(44100)); + ics.info.long_win = true; + ics.info.window_groups = 1; + ics.info.max_sfb = 1; + + let mut bs = BitReaderLtr::new(&buf); + assert!(ics.decode_section_data(&mut bs).is_err()); + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/ics/pulse.rs b/vendor/symphonia-codec-aac/src/aac/ics/pulse.rs new file mode 100644 index 00000000..97258338 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/ics/pulse.rs @@ -0,0 +1,106 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::Result; +use symphonia_core::io::ReadBitsLtr; + +use crate::aac::common::{MAX_SFBS, MAX_WINDOWS}; + +#[inline(always)] +fn iquant(val: f32) -> f32 { + if val < 0.0 { -((-val).powf(4.0 / 3.0)) } else { val.powf(4.0 / 3.0) } +} + +#[inline(always)] +fn requant(val: f32, scale: f32) -> f32 { + if scale == 0.0 { + return 0.0; + } + let bval = val / scale; + if bval >= 0.0 { val.powf(3.0 / 4.0) } else { -((-val).powf(3.0 / 4.0)) } +} + +#[derive(Clone, Copy)] +#[allow(dead_code)] +pub struct Pulse { + number_pulse: usize, + pulse_start_sfb: usize, + pulse_offset: [u8; 4], + pulse_amp: [u8; 4], +} + +impl Pulse { + pub fn read(bs: &mut B) -> Result> { + let pulse_data_present = bs.read_bool()?; + + if !pulse_data_present { + return Ok(None); + } + + let number_pulse = bs.read_bits_leq32(2)? as usize + 1; + let pulse_start_sfb = bs.read_bits_leq32(6)? as usize; + + let mut pulse_offset: [u8; 4] = [0; 4]; + let mut pulse_amp: [u8; 4] = [0; 4]; + + for i in 0..number_pulse { + pulse_offset[i] = bs.read_bits_leq32(5)? as u8; + pulse_amp[i] = bs.read_bits_leq32(4)? as u8; + } + + Ok(Some(Self { number_pulse, pulse_start_sfb, pulse_offset, pulse_amp })) + } + + pub fn synth( + &self, + bands: &[usize], + scales: &[[f32; MAX_SFBS]; MAX_WINDOWS], + coeffs: &mut [f32; 1024], + ) { + if self.pulse_start_sfb >= bands.len() - 1 { + return; + } + + let mut k = bands[self.pulse_start_sfb]; + + let mut band = self.pulse_start_sfb; + + for pno in 0..self.number_pulse { + k += self.pulse_offset[pno] as usize; + + if k >= 1024 { + return; + } + + while bands[band + 1] <= k { + band += 1; + } + + let scale = scales[0][band]; + + let mut base = coeffs[k]; + + if base != 0.0 { + base = requant(coeffs[k], scale); + } + + if base > 0.0 { + base += f32::from(self.pulse_amp[pno]); + } + else { + base -= f32::from(self.pulse_amp[pno]); + } + coeffs[k] = iquant(base) * scale; + } + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/ics/tns.rs b/vendor/symphonia-codec-aac/src/aac/ics/tns.rs new file mode 100644 index 00000000..b2d9bfd1 --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/ics/tns.rs @@ -0,0 +1,200 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::Result; +use symphonia_core::io::ReadBitsLtr; + +use std::f32::consts; + +use crate::aac::common::*; +use crate::aac::ics::IcsInfo; + +const TNS_MAX_ORDER: usize = 20; +const TNS_MAX_LONG_BANDS: [usize; 12] = [31, 31, 34, 40, 42, 51, 46, 46, 42, 42, 42, 39]; +const TNS_MAX_SHORT_BANDS: [usize; 12] = [9, 9, 10, 14, 14, 14, 14, 14, 14, 14, 14, 14]; + +#[derive(Copy, Clone)] +struct TnsCoeffs { + length: usize, + order: usize, + direction: bool, + coef: [f32; TNS_MAX_ORDER + 1], +} + +impl TnsCoeffs { + fn new() -> Self { + Self { length: 0, order: 0, direction: false, coef: [0.0; TNS_MAX_ORDER + 1] } + } + + fn read( + &mut self, + bs: &mut B, + long_win: bool, + coef_res: bool, + max_order: usize, + ) -> Result<()> { + self.length = bs.read_bits_leq32(if long_win { 6 } else { 4 })? as usize; + self.order = bs.read_bits_leq32(if long_win { 5 } else { 3 })? as usize; + + validate!(self.order <= max_order); + + if self.order > 0 { + self.direction = bs.read_bool()?; + + let coef_compress = bs.read_bool()?; + + // If coef_res is true, then the transmitted resolution of the filter coefficients + // is 4 bits, otherwise it's 3 (4.6.9.2). + let mut coef_res_bits = if coef_res { 4 } else { 3 }; + + // If true, the most significant bit of the filter coefficient is not transmitted + // (4.6.9.2). + if coef_compress { + coef_res_bits -= 1; + } + + let sign_mask = 1 << (coef_res_bits - 1); + let neg_mask = !((1 << coef_res_bits) - 1); + + // Derived from `1 << (coef_res_bits - 1)` before compression. + let fac_base = if coef_res { 8.0 } else { 4.0 }; + + let iqfac = (fac_base - 0.5) / consts::FRAC_PI_2; + let iqfac_m = (fac_base + 0.5) / consts::FRAC_PI_2; + + let mut tmp: [f32; TNS_MAX_ORDER] = [0.0; TNS_MAX_ORDER]; + + for el in tmp[..self.order].iter_mut() { + let val = bs.read_bits_leq32(coef_res_bits)? as u8; + + // Convert to signed integer. + let c = f32::from(if (val & sign_mask) != 0 { + (val | neg_mask) as i8 + } + else { + val as i8 + }); + + *el = (if c >= 0.0 { c / iqfac } else { c / iqfac_m }).sin(); + } + + // Generate LPC coefficients + let mut b: [f32; TNS_MAX_ORDER + 1] = [0.0; TNS_MAX_ORDER + 1]; + + for m in 1..=self.order { + for i in 1..m { + b[i] = self.coef[i - 1] + tmp[m - 1] * self.coef[m - i - 1]; + } + + self.coef[..(m - 1)].copy_from_slice(&b[1..m]); + self.coef[m - 1] = tmp[m - 1]; + } + } + + Ok(()) + } +} + +#[derive(Copy, Clone)] +pub struct Tns { + n_filt: [usize; MAX_WINDOWS], + coeffs: [[TnsCoeffs; 4]; MAX_WINDOWS], +} + +impl Tns { + pub fn read(bs: &mut B, info: &IcsInfo, is_lc: bool) -> Result> { + let tns_data_present = bs.read_bool()?; + + if !tns_data_present { + return Ok(None); + } + + // Table 4.156 + let max_order = if !info.long_win { + 7 + } + else if is_lc { + 12 + } + else { + TNS_MAX_ORDER + }; + + let mut n_filt: [usize; MAX_WINDOWS] = [0; MAX_WINDOWS]; + let mut coeffs: [[TnsCoeffs; 4]; MAX_WINDOWS] = [[TnsCoeffs::new(); 4]; MAX_WINDOWS]; + + for w in 0..info.num_windows { + n_filt[w] = bs.read_bits_leq32(if info.long_win { 2 } else { 1 })? as usize; + + let coef_res = if n_filt[w] != 0 { bs.read_bool()? } else { false }; + + for filt in 0..n_filt[w] { + coeffs[w][filt].read(bs, info.long_win, coef_res, max_order)?; + } + } + + Ok(Some(Self { n_filt, coeffs })) + } + + pub fn synth( + &self, + info: &IcsInfo, + bands: &[usize], + rate_idx: usize, + coeffs: &mut [f32; 1024], + ) { + let tns_max_bands = (if info.long_win { + TNS_MAX_LONG_BANDS[rate_idx] + } + else { + TNS_MAX_SHORT_BANDS[rate_idx] + }) + .min(info.max_sfb); + + for w in 0..info.num_windows { + let mut bottom = bands.len() - 1; + + for f in 0..self.n_filt[w] { + let top = bottom; + + bottom = top.saturating_sub(self.coeffs[w][f].length); + + let order = self.coeffs[w][f].order; + + if order == 0 { + continue; + } + + let start = w * 128 + bands[bottom.min(tns_max_bands)]; + let end = w * 128 + bands[top.min(tns_max_bands)]; + + let lpc = &self.coeffs[w][f].coef; + + if !self.coeffs[w][f].direction { + for (m, i) in (start..end).enumerate() { + for j in 0..order.min(m) { + coeffs[i] -= coeffs[i - j - 1] * lpc[j]; + } + } + } + else { + for (m, i) in (start..end).rev().enumerate() { + for j in 0..order.min(m) { + coeffs[i] -= coeffs[i + j + 1] * lpc[j]; + } + } + } + } + } + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/mod.rs b/vendor/symphonia-codec-aac/src/aac/mod.rs new file mode 100644 index 00000000..8031ca9c --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/mod.rs @@ -0,0 +1,310 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::audio::{ + AsGenericAudioBufferRef, AudioBuffer, AudioSpec, GenericAudioBufferRef, +}; +use symphonia_core::codecs::CodecInfo; +use symphonia_core::codecs::audio::well_known::CODEC_ID_AAC; +use symphonia_core::codecs::audio::{AudioCodecParameters, AudioDecoderOptions}; +use symphonia_core::codecs::audio::{AudioDecoder, FinalizeResult}; +use symphonia_core::codecs::registry::{RegisterableAudioDecoder, SupportedAudioCodec}; +use symphonia_core::errors::{Result, unsupported_error}; +use symphonia_core::io::{BitReaderLtr, FiniteBitStream, ReadBitsLtr}; +use symphonia_core::packet::PacketRef; +use symphonia_core::{codec_profile, support_audio_codec}; + +use symphonia_common::mpeg::audio::{AudioObjectType, AudioSpecificConfig}; + +mod codebooks; +mod common; +mod cpe; +mod dsp; +mod ics; +mod window; + +use common::*; + +/// Advanced Audio Coding (AAC) decoder. +/// +/// Implements a decoder for Advanced Audio Decoding Low-Complexity (AAC-LC) as defined in +/// ISO/IEC 13818-7 and ISO/IEC 14496-3. +pub struct AacDecoder { + // info: NACodecInfoRef, + asc: AudioSpecificConfig, + pairs: Vec, + dsp: dsp::Dsp, + sbinfo: GASubbandInfo, + params: AudioCodecParameters, + buf: AudioBuffer, +} + +impl AacDecoder { + pub fn try_new(params: &AudioCodecParameters, _opts: &AudioDecoderOptions) -> Result { + // This decoder only supports AAC. + if params.codec != CODEC_ID_AAC { + return unsupported_error("aac: invalid codec"); + } + + // If extra data present, parse the audio specific config + let asc = if let Some(extra_data_buf) = ¶ms.extra_data { + validate!(extra_data_buf.len() >= 2); + AudioSpecificConfig::read(extra_data_buf)? + } + else { + // Otherwise, assume there is no ASC and use the codec parameters for ADTS. + let mut asc = AudioSpecificConfig::default(); + + asc.object_type = AudioObjectType::Lc; + asc.samples = 1024; + + asc.sample_rate = match params.sample_rate { + Some(rate) => rate, + None => return unsupported_error("aac: sample rate is required"), + }; + + asc.channels = params.channels.clone(); + + asc + }; + + // The channel configuration must be known. + // + // TODO: Support getting this from program configuration element (PCE). However, this would + // require deferring the rest of the initialization until the PCE has been read. + let channels = match &asc.channels { + Some(channels) => channels.clone(), + _ => return unsupported_error("aac: channels or channel layout is required"), + }; + + // Check complexity. + if asc.object_type != AudioObjectType::Lc + || asc.sbr_present + || channels.count() > 2 + || asc.samples != 1024 + { + return unsupported_error("aac: aac too complex"); + } + + // Clone and amend the codec parameters with information from the extra data. + let mut params = params.clone(); + + params.with_channels(channels.clone()).with_sample_rate(asc.sample_rate); + + let sbinfo = GASubbandInfo::find(asc.sample_rate); + + let buf = AudioBuffer::new(AudioSpec::new(asc.sample_rate, channels), asc.samples); + + Ok(AacDecoder { asc, pairs: Vec::new(), dsp: dsp::Dsp::new(), sbinfo, params, buf }) + } + + fn set_pair(&mut self, pair_no: usize, channel: usize, pair: bool) -> Result<()> { + if self.pairs.len() <= pair_no { + self.pairs.push(cpe::ChannelPair::new(pair, channel, self.sbinfo)); + } + else { + validate!(self.pairs[pair_no].channel == channel); + validate!(self.pairs[pair_no].is_pair == pair); + } + + let max_channels = self.asc.channels.as_ref().map_or(0, |channels| channels.count()); + validate!(if pair { channel + 1 } else { channel } < max_channels); + + Ok(()) + } + + fn decode_ga(&mut self, bs: &mut B) -> Result<()> { + let mut cur_pair = 0; + let mut cur_ch = 0; + while bs.bits_left() > 3 { + let id = bs.read_bits_leq32(3)?; + + match id { + 0 => { + // ID_SCE + let _tag = bs.read_bits_leq32(4)?; + self.set_pair(cur_pair, cur_ch, false)?; + self.pairs[cur_pair].decode_ga_sce(bs, self.asc.object_type)?; + cur_pair += 1; + cur_ch += 1; + } + 1 => { + // ID_CPE + let _tag = bs.read_bits_leq32(4)?; + self.set_pair(cur_pair, cur_ch, true)?; + self.pairs[cur_pair].decode_ga_cpe(bs, self.asc.object_type)?; + cur_pair += 1; + cur_ch += 2; + } + 2 => { + // ID_CCE + return unsupported_error("aac: coupling channel element"); + } + 3 => { + // ID_LFE + let _tag = bs.read_bits_leq32(4)?; + self.set_pair(cur_pair, cur_ch, false)?; + self.pairs[cur_pair].decode_ga_sce(bs, self.asc.object_type)?; + cur_pair += 1; + cur_ch += 1; + } + 4 => { + // ID_DSE + let _id = bs.read_bits_leq32(4)?; + let align = bs.read_bool()?; + let mut count = bs.read_bits_leq32(8)?; + if count == 255 { + count += bs.read_bits_leq32(8)?; + } + if align { + bs.realign(); // ???? + } + bs.ignore_bits(count * 8)?; // no SBR payload or such + } + 5 => { + // ID_PCE + return unsupported_error("aac: program config"); + } + 6 => { + // ID_FIL + let mut count = bs.read_bits_leq32(4)? as usize; + if count == 15 { + count += bs.read_bits_leq32(8)? as usize; + count -= 1; + } + + // Check if the ID_FIL element contains SBR data. Note that ID_FIL elements with + // SBR data may not contain other extension payloads. + if count > 0 { + let ext_type = bs.read_bits_leq32(4)?; + + match ext_type { + // EXT_SBR_DATA (0xd) + // EXT_SBR_DATA_CRC (0xe) + 0xd | 0xe => self.asc.sbr_present = true, + // EXT_FILL (0x0) + // EXT_FILL_DATA (0x1) + // EXT_DATA_ELEMENT (0x2) + // EXT_DYNAMIC_RANGE (0xb) + // EXT_SAC_DATA (0xc) + _ => (), + } + + // Ignore extension payload(s). + bs.ignore_bits(4)?; + for _ in 0..count - 1 { + bs.ignore_bits(8)?; + } + } + } + 7 => { + // ID_TERM + break; + } + _ => unreachable!(), + }; + } + // RustChan patch: SBR fill elements were parsed but silently decoded as AAC-LC. + // Check after the complete raw-data block, so truncated extension payloads + // retain their structural error instead of selecting a compatibility path. + if self.asc.sbr_present { + return unsupported_error("aac: spectral band replication is unsupported"); + } + let rate_idx = GASubbandInfo::find_idx(self.asc.sample_rate); + for pair in 0..cur_pair { + self.pairs[pair].synth_audio(&mut self.dsp, &mut self.buf, rate_idx); + } + Ok(()) + } + + // fn flush(&mut self) { + // for pair in self.pairs.iter_mut() { + // pair.ics[0].delay = [0.0; 1024]; + // pair.ics[1].delay = [0.0; 1024]; + // } + // } + + fn decode_inner(&mut self, packet: &PacketRef<'_>) -> Result<()> { + // Clear the audio output buffer. + self.buf.clear(); + self.buf.render_uninit(None); + + let mut bs = BitReaderLtr::new(packet.data); + + // Choose decode step based on the object type. + match self.asc.object_type { + AudioObjectType::Lc => self.decode_ga(&mut bs)?, + _ => return unsupported_error("aac: object type"), + } + + Ok(()) + } +} + +impl AudioDecoder for AacDecoder { + fn reset(&mut self) { + for pair in self.pairs.iter_mut() { + pair.reset(); + } + } + + fn codec_info(&self) -> &CodecInfo { + // Only one codec is supported. + &Self::supported_codecs().first().expect("at least one codec registered").info + } + + fn codec_params(&self) -> &AudioCodecParameters { + &self.params + } + + fn decode_ref(&mut self, packet: &PacketRef<'_>) -> Result> { + if let Err(e) = self.decode_inner(packet) { + self.buf.clear(); + Err(e) + } + else { + Ok(self.buf.as_generic_audio_buffer_ref()) + } + } + + fn finalize(&mut self) -> FinalizeResult { + Default::default() + } + + fn last_decoded(&self) -> GenericAudioBufferRef<'_> { + self.buf.as_generic_audio_buffer_ref() + } +} + +impl RegisterableAudioDecoder for AacDecoder { + fn try_registry_new( + params: &AudioCodecParameters, + opts: &AudioDecoderOptions, + ) -> Result> + where + Self: Sized, + { + Ok(Box::new(AacDecoder::try_new(params, opts)?)) + } + + fn supported_codecs() -> &'static [SupportedAudioCodec] { + use symphonia_core::codecs::audio::well_known::profiles::CODEC_PROFILE_AAC_LC; + + &[support_audio_codec!( + CODEC_ID_AAC, + "aac", + "Advanced Audio Coding", + &[codec_profile!(CODEC_PROFILE_AAC_LC, "aac-lc", "Low Complexity"),] + )] + } +} diff --git a/vendor/symphonia-codec-aac/src/aac/window.rs b/vendor/symphonia-codec-aac/src/aac/window.rs new file mode 100644 index 00000000..697c3fbf --- /dev/null +++ b/vendor/symphonia-codec-aac/src/aac/window.rs @@ -0,0 +1,63 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// Previous Author: Kostya Shishkov +// +// This source file includes code originally written for the NihAV +// project. With the author's permission, it has been relicensed for, +// and ported to the Symphonia project. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::f32::consts; + +/// Window types. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WindowType { + /// Simple sine window. + Sine, + /// Kaiser-Bessel derived window. + KaiserBessel(f32), +} + +/// Calculates window coefficients for the requested window type and size. +/// +/// Set `half` flag to calculate only the first half of the window. +pub fn generate_window(mode: WindowType, scale: f32, size: usize, half: bool, dst: &mut [f32]) { + match mode { + WindowType::Sine => { + let param = + if half { consts::PI / ((2 * size) as f32) } else { consts::PI / (size as f32) }; + for n in 0..size { + dst[n] = (((n as f32) + 0.5) * param).sin() * scale; + } + } + WindowType::KaiserBessel(alpha) => { + let dlen = if half { size as f32 } else { (size as f32) * 0.5 }; + let alpha2 = f64::from((alpha * consts::PI / dlen) * (alpha * consts::PI / dlen)); + + let mut kb: Vec = Vec::with_capacity(size); + let mut sum = 0.0; + for n in 0..size { + let b = bessel_i0(((n * (size - n)) as f64) * alpha2); + sum += b; + kb.push(sum); + } + sum += 1.0; + for n in 0..size { + dst[n] = (kb[n] / sum).sqrt() as f32; + } + } + }; +} + +fn bessel_i0(inval: f64) -> f64 { + let mut val: f64 = 1.0; + for n in (1..64).rev() { + val *= inval / f64::from(n * n); + val += 1.0; + } + val +} diff --git a/vendor/symphonia-codec-aac/src/adts.rs b/vendor/symphonia-codec-aac/src/adts.rs new file mode 100644 index 00000000..172fe83f --- /dev/null +++ b/vendor/symphonia-codec-aac/src/adts.rs @@ -0,0 +1,488 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use symphonia_core::errors::{Error, unsupported_error}; +use symphonia_core::support_format; + +use symphonia_core::audio::Channels; +use symphonia_core::codecs::CodecParameters; +use symphonia_core::codecs::audio::AudioCodecParameters; +use symphonia_core::codecs::audio::well_known::CODEC_ID_AAC; +use symphonia_core::errors::{Result, SeekErrorKind, decode_error, seek_error}; +use symphonia_core::formats::prelude::*; +use symphonia_core::formats::probe::{ProbeFormatData, ProbeableFormat, Score, Scoreable}; +use symphonia_core::formats::well_known::FORMAT_ID_ADTS; +use symphonia_core::io::*; +use symphonia_core::meta::{Metadata, MetadataLog}; + +use symphonia_common::mpeg::audio::*; + +use std::io::{Seek, SeekFrom}; + +use log::{debug, info}; + +const SAMPLES_PER_AAC_PACKET: Duration = Duration::new(1024); + +const ADTS_FORMAT_INFO: FormatInfo = FormatInfo { + format: FORMAT_ID_ADTS, + short_name: "aac", + long_name: "Audio Data Transport Stream (native AAC)", +}; + +/// Audio Data Transport Stream (ADTS) format reader. +/// +/// `AdtsReader` implements a demuxer for ADTS (AAC native frames). +pub struct AdtsReader<'s> { + reader: MediaSourceStream<'s>, + media_info: MediaInfo, + tracks: Vec, + chapters: Option, + metadata: MetadataLog, + first_frame_pos: u64, + next_packet_ts: Timestamp, +} + +impl<'s> AdtsReader<'s> { + pub fn try_new(mut mss: MediaSourceStream<'s>, opts: FormatOptions) -> Result { + let header = AdtsHeader::read(&mut mss)?; + + // Rewind back to the start of the frame. + mss.seek_buffered_rev(usize::from(header.header_len())); + + // Use the header to populate the codec parameters. + let mut codec_params = AudioCodecParameters::new(); + + codec_params.for_codec(CODEC_ID_AAC).with_sample_rate(header.sample_rate); + + if let Some(channels) = header.channels { + codec_params.with_channels(channels); + } + + // Populat the track. + let mut track = Track::new(0); + track.with_codec_params(CodecParameters::Audio(codec_params)); + + let first_frame_pos = mss.pos(); + + if let Some(num_frames) = approximate_frame_count(&mut mss)? { + info!("estimating duration from bitrate, may be inaccurate for vbr files"); + track.with_num_frames(num_frames); + // Duration equals the number of frames because the timebase is always 1 / sample rate. + track.with_duration(Duration::from(num_frames)); + } + + Ok(AdtsReader { + reader: mss, + media_info: MediaInfo::from_track(&track), + tracks: vec![track], + chapters: opts.external_data.chapters, + metadata: opts.external_data.metadata.unwrap_or_default(), + first_frame_pos, + next_packet_ts: Timestamp::new(0), + }) + } +} + +impl Scoreable for AdtsReader<'_> { + fn score(mut src: ScopedStream<&mut MediaSourceStream<'_>>) -> Result { + // Read the first (assumed) ADTS header. + let hdr1 = AdtsHeader::read_no_resync(&mut src)?; + + // Since the first header was read successfully, this may be an ADTS audio format. However, + // if there is enough data left to read the frame body and another frame header, then a + // higher confidence may be gained. If there is not enough data left, return a partially + // confident score. + let payload_len = hdr1.payload_len(); + + if src.bytes_available() < u64::from(payload_len + AdtsHeader::SIZE_WITH_CRC) { + return Ok(Score::Supported(127)); + } + + src.ignore_bytes(u64::from(payload_len))?; + + let _ = AdtsHeader::read_no_resync(&mut src)?; + + Ok(Score::Supported(255)) + } +} + +#[derive(Debug)] +#[allow(dead_code)] +struct AdtsHeader { + /// Audio profile. + profile: AudioObjectType, + /// Audio channel configuration. + channels: Option, + /// The sample rate in Hertz. + sample_rate: u32, + /// The length of the ADTS frame in bytes including the sync word, header, and payload. Maximum + /// value is 8kB. + frame_len: u16, + /// An optional CRC. + crc: Option, +} + +impl AdtsHeader { + /// The size of the an ADTS header CRC. + pub const CRC_SIZE: u16 = 2; + /// The size of a ADTS header including the sync word and no a CRC. + pub const SIZE_NO_CRC: u16 = 7; + /// The size of a ADTS header including the sync word and CRC. + pub const SIZE_WITH_CRC: u16 = Self::SIZE_NO_CRC + Self::CRC_SIZE; + + /// Read the body of a header at the current position of the reader. + fn read_body(reader: &mut B, has_crc: bool) -> Result { + // The length of the header. + let len = if has_crc { Self::SIZE_WITH_CRC } else { Self::SIZE_NO_CRC }; + + // Read the body of the header (no sync word). + let mut buf = [0; 7]; + reader.read_buf_exact(&mut buf[..usize::from(len - 2)])?; + + let mut bs = BitReaderLtr::new(&buf); + + // Profile (audio object type). + let profile = match get_mpeg4_audio_object_type_by_index(bs.read_bits_leq32(2)? + 1) { + Some(p) => p, + None => return decode_error("adts: invalid audio object type"), + }; + + // Sample rate from sample rate index. + let sample_rate = match get_mpeg4_audio_sample_rate_by_index(bs.read_bits_leq32(4)?) { + Mpeg4AudioSampleRate::SampleRate(rate) => rate, + Mpeg4AudioSampleRate::Escape => return decode_error("adts: forbidden sample rate"), + Mpeg4AudioSampleRate::Invalid => return decode_error("adts: invalid sample rate"), + }; + + // Private bit. + bs.ignore_bit()?; + + // Channel configuration. + let channels = match get_mpeg4_audio_channels_by_config_index(bs.read_bits_leq32(3)?) { + Mpeg4AudioChannels::Channels(channels) => Some(channels), + Mpeg4AudioChannels::Escape => None, + Mpeg4AudioChannels::Invalid => { + return decode_error("adts: invalid channel configuration"); + } + }; + + // Originality, Home, Copyrighted ID bit, Copyright ID start bits. Only used for encoding. + bs.ignore_bits(4)?; + + // The frame length = sync word + header + payload. + let frame_len = bs.read_bits_leq32(13)? as u16; + + // The frame length must be large enough for the header. + if frame_len < len { + return decode_error("adts: invalid adts frame length"); + } + + // Buffer fullness. + let _fullness = bs.read_bits_leq32(11)?; + + // Number of raw data blocks (AAC packets). + let raw_data_blocks = bs.read_bits_leq32(2)? + 1; + + if raw_data_blocks > 1 { + // TODO: Support multiple AAC packets per ADTS packet. + return unsupported_error("adts: only 1 aac frame per adts packet is supported"); + } + + // The CRC, if the CRC is provided. + let crc = if has_crc { Some(bs.read_bits_leq32(16)? as u16) } else { None }; + + Ok(AdtsHeader { profile, channels, sample_rate, frame_len, crc }) + } + + /// Returns true if the provided word is a valid sync word. + #[inline(always)] + fn is_sync_word(sync: u16) -> bool { + (sync & 0xfff6) == 0xfff0 + } + + /// Resync the reader to the next sync word. + fn sync(reader: &mut B) -> Result { + let mut sync = 0; + + while !Self::is_sync_word(sync) { + sync = (sync << 8) | u16::from(reader.read_u8()?); + } + + Ok(sync) + } + + /// Read a header from the current position of the reader. + fn read_no_resync(reader: &mut B) -> Result { + let sync = reader.read_be_u16()?; + + if !Self::is_sync_word(sync) { + return decode_error("adts: invalid frame sync word"); + } + + // "Protection absent" set to 0 if CRC is present. + Self::read_body(reader, sync & 1 == 0) + } + + /// Resync the reader if required, and read a header. + fn read(reader: &mut B) -> Result { + let sync = AdtsHeader::sync(reader)?; + + // "Protection absent" set to 0 if CRC is present. + Self::read_body(reader, sync & 1 == 0) + } + + /// Get the length of the header including the sync word. + #[inline] + fn header_len(&self) -> u16 { + Self::SIZE_NO_CRC + if self.crc.is_some() { Self::CRC_SIZE } else { 0 } + } + + /// Get the length of the payload. + #[inline] + fn payload_len(&self) -> u16 { + self.frame_len - self.header_len() + } +} + +impl ProbeableFormat<'_> for AdtsReader<'_> { + fn try_probe_new( + mss: MediaSourceStream<'_>, + opts: FormatOptions, + ) -> Result> { + Ok(Box::new(AdtsReader::try_new(mss, opts)?)) + } + + fn probe_data() -> &'static [ProbeFormatData] { + &[support_format!( + ADTS_FORMAT_INFO, + &["aac"], + &["audio/aac"], + &[ + &[0xff, 0xf1], // MPEG 4 without CRC + &[0xff, 0xf0], // MPEG 4 with CRC + &[0xff, 0xf9], // MPEG 2 without CRC + &[0xff, 0xf8], // MPEG 2 with CRC + ] + )] + } +} + +impl FormatReader for AdtsReader<'_> { + fn format_info(&self) -> &FormatInfo { + &ADTS_FORMAT_INFO + } + + fn media_info(&self) -> &MediaInfo { + &self.media_info + } + + fn next_packet(&mut self) -> Result> { + // Parse the header to get the calculated frame size. + let header = match AdtsHeader::read(&mut self.reader) { + Ok(header) => header, + Err(Error::IoError(err)) if err.kind() == std::io::ErrorKind::UnexpectedEof => { + // ADTS streams have no well-defined end, so when no more frames can be read, + // consider the stream ended. + return Ok(None); + } + Err(err) => return Err(err), + }; + + // TODO: Support multiple AAC packets per ADTS packet. + + let ts = self.next_packet_ts; + + self.next_packet_ts = match self.next_packet_ts.checked_add(SAMPLES_PER_AAC_PACKET) { + Some(ts) => ts, + None => return Ok(None), + }; + + Ok(Some(Packet::new( + 0, + ts, + SAMPLES_PER_AAC_PACKET, + self.reader.read_boxed_slice_exact(usize::from(header.payload_len()))?, + ))) + } + + fn metadata(&mut self) -> Metadata<'_> { + self.metadata.metadata() + } + + fn chapters(&self) -> Option<&ChapterGroup> { + self.chapters.as_ref() + } + + fn tracks(&self) -> &[Track] { + &self.tracks + } + + fn seek(&mut self, _mode: SeekMode, to: SeekTo) -> Result { + // Get the timestamp of the desired audio frame. + let required_ts = match to { + // Frame timestamp given. + SeekTo::Timestamp { ts, .. } => ts, + // Time value given, calculate frame timestamp using the timebase. + SeekTo::Time { time, .. } => { + // The timebase is required to calculate the timestamp. + let tb = + self.tracks[0].time_base.ok_or(Error::SeekError(SeekErrorKind::Unseekable))?; + + // If the timestamp overflows, the seek if out-of-range. + tb.calc_timestamp(time).ok_or(Error::SeekError(SeekErrorKind::OutOfRange))? + } + }; + + debug!("seeking to ts={required_ts}"); + + // If the desired timestamp is less-than the next packet timestamp, attempt to seek + // to the start of the stream. + if required_ts < self.next_packet_ts { + // If the reader is not seekable then only forward seeks are possible. + if self.reader.is_seekable() { + let seeked_pos = self.reader.seek(SeekFrom::Start(self.first_frame_pos))?; + + // Since the elementary stream has no timestamp information, the position seeked + // to must be exactly as requested. + if seeked_pos != self.first_frame_pos { + return seek_error(SeekErrorKind::Unseekable); + } + } + else { + return seek_error(SeekErrorKind::ForwardOnly); + } + + // Successfuly seeked to the start of the stream, reset the next packet timestamp. + self.next_packet_ts = Timestamp::from(0); + } + + // Parse frames from the stream until the frame containing the desired timestamp is + // reached. + loop { + // Parse the next frame header. + let header = match AdtsHeader::read(&mut self.reader) { + Ok(header) => header, + Err(Error::IoError(err)) if err.kind() == std::io::ErrorKind::UnexpectedEof => { + // ADTS streams have no well-defined end, so if no more frames can be read then + // assume the seek position is out-of-range. + return seek_error(SeekErrorKind::OutOfRange); + } + Err(err) => return Err(err), + }; + + // TODO: Support multiple AAC packets per ADTS packet. + + let next_packet_ts = match self.next_packet_ts.checked_add(SAMPLES_PER_AAC_PACKET) { + Some(ts) if ts <= required_ts => ts, + // If the next frame's timestamp would exceed the desired timestamp, or it + // exceeds the representable range, rewind back to the start of this frame and end + // the search. + _ => { + self.reader.seek_buffered_rev(usize::from(header.header_len())); + break; + } + }; + + // Ignore the frame body. + self.reader.ignore_bytes(u64::from(header.payload_len()))?; + + // Increment the timestamp for the next packet. + self.next_packet_ts = next_packet_ts; + } + + debug!( + "seeked to ts={} (delta={})", + self.next_packet_ts, + required_ts.saturating_delta(self.next_packet_ts), + ); + + Ok(SeekedTo { track_id: 0, required_ts, actual_ts: self.next_packet_ts }) + } + + fn into_inner<'s>(self: Box) -> MediaSourceStream<'s> + where + Self: 's, + { + self.reader + } +} + +fn approximate_frame_count(mut source: &mut MediaSourceStream<'_>) -> Result> { + let original_pos = source.pos(); + let remaining_len = match source.byte_len() { + Some(len) => len - original_pos, + _ => return Ok(None), + }; + + let mut parsed_n_frames = 0; + let mut n_bytes = 0; + + if !source.is_seekable() { + // The maximum length in bytes of frames to consume from the stream to sample. + const MAX_LEN: u64 = 16 * 1024; + + source.ensure_seekback_buffer(MAX_LEN as usize); + let mut scoped_stream = ScopedStream::new(&mut source, MAX_LEN); + + while let Ok(header) = AdtsHeader::read(&mut scoped_stream) { + if scoped_stream.ignore_bytes(u64::from(header.payload_len())).is_err() { + break; + } + + parsed_n_frames += 1; + n_bytes += u64::from(header.frame_len); + } + + let _ = source.seek_buffered(original_pos); + } + else { + // The number of points to sample within the stream. + const NUM_SAMPLE_POINTS: u64 = 4; + const NUM_FRAMES_PER_SAMPLE_POINT: u32 = 100; + + let step = remaining_len / NUM_SAMPLE_POINTS; + + // file can be small enough and not have enough NUM_FRAMES_PER_SAMPLE_POINT, but we can + // still read at least one frame + if step > 0 { + for new_pos in (original_pos..(original_pos + remaining_len)).step_by(step as usize) { + // Skip sample point if previous read exceeded its boundary. + if new_pos < source.pos() { + continue; + } + + if source.seek(SeekFrom::Start(new_pos)).is_err() { + break; + } + + for _ in 0..NUM_FRAMES_PER_SAMPLE_POINT { + let header = match AdtsHeader::read(&mut source) { + Ok(header) => header, + _ => break, + }; + + parsed_n_frames += 1; + n_bytes += u64::from(header.frame_len); + + // skip frame payload to avoid seaching the sync word in the audio data + if source.ignore_bytes(header.payload_len() as u64).is_err() { + break; + } + } + } + } + + let _ = source.seek(SeekFrom::Start(original_pos))?; + } + + debug!("adts: parsed {n_bytes} of {remaining_len} bytes to approximate duration"); + + match parsed_n_frames { + 0 => Ok(None), + _ => Ok(Some(remaining_len / (n_bytes / parsed_n_frames) * SAMPLES_PER_AAC_PACKET.get())), + } +} diff --git a/vendor/symphonia-codec-aac/src/lib.rs b/vendor/symphonia-codec-aac/src/lib.rs new file mode 100644 index 00000000..b6c10fbd --- /dev/null +++ b/vendor/symphonia-codec-aac/src/lib.rs @@ -0,0 +1,23 @@ +// Symphonia +// Copyright (c) 2019-2026 The Project Symphonia Developers. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#![warn(rust_2018_idioms)] +#![forbid(unsafe_code)] +// The following lints are allowed in all Symphonia crates. Please see clippy.toml for their +// justification. +#![allow(clippy::comparison_chain)] +#![allow(clippy::excessive_precision)] +#![allow(clippy::identity_op)] +#![allow(clippy::manual_range_contains)] +// TODO: Remove this when refactoring AAC. +#![allow(clippy::needless_range_loop)] + +mod aac; +mod adts; + +pub use aac::AacDecoder; +pub use adts::AdtsReader; diff --git a/vendor/symphonia-codec-aac/tests/tests.rs b/vendor/symphonia-codec-aac/tests/tests.rs new file mode 100644 index 00000000..9cfdac15 --- /dev/null +++ b/vendor/symphonia-codec-aac/tests/tests.rs @@ -0,0 +1,43 @@ +use symphonia_codec_aac::{AacDecoder, AdtsReader}; +use symphonia_core::codecs::audio::{ + AudioCodecParameters, AudioDecoder, AudioDecoderOptions, well_known::CODEC_ID_AAC, +}; +use symphonia_core::errors; +use symphonia_core::formats::probe::ProbeableFormat; +use symphonia_core::io::MediaSourceStream; + +fn test_decode(data: Vec) -> symphonia_core::errors::Result<()> { + let data = std::io::Cursor::new(data); + + let mss = MediaSourceStream::new(Box::new(data), Default::default()); + + let mut reader = AdtsReader::try_probe_new(mss, Default::default())?; + + let mut decoder = AacDecoder::try_new( + AudioCodecParameters::new().for_codec(CODEC_ID_AAC), + &AudioDecoderOptions::default(), + )?; + + loop { + match reader.next_packet()? { + Some(packet) => { + let _ = decoder.decode(&packet); + } + None => break, + }; + } + + Ok(()) +} + +#[test] +fn invalid_channels_aac() { + let file = vec![ + 0xff, 0xf1, 0xaf, 0xce, 0x02, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xfb, + 0xaf, + ]; + + let err = test_decode(file).unwrap_err(); + + assert!(matches!(err, errors::Error::Unsupported(_))); +} From 8082fdb737355b8b6881c45f3ba3f6690cae8bf9 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 00:22:32 -0700 Subject: [PATCH 05/11] Fix Debian Speex waveform layouts and Windows restart builds --- docs/media-capabilities.md | 10 +++++----- src/config/admin.rs | 1 + src/workers/mod.rs | 18 +++++++++++++++++- tests/fixtures/media/README.md | 2 ++ tests/fixtures/media/speex-stereo.spx | Bin 0 -> 910 bytes 5 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/media/speex-stereo.spx diff --git a/docs/media-capabilities.md b/docs/media-capabilities.md index c08efde0..792436e3 100644 --- a/docs/media-capabilities.md +++ b/docs/media-capabilities.md @@ -18,7 +18,7 @@ This is the current capability audit for `ffmpeg-replace-with-rust`, superseding | Opus voice, stereo and surround | FFmpeg/libopus; earlier Rust surround fallback | `opus-pure` plus Rust multistream framing/mapping | Ogg families 0, 1 and single-stream-container family 255; SILK/CELT, self-delimited VBR/CBR/padding, gain, pre-skip and end trim. Three six-channel golden clips have **11,520** frames and per-channel PCM error ≤0.00004; channel-mean envelope compared independently. Public family 255 probing and decoding are tested | | Other Opus mappings / containers | FFmpeg accepted mappings supported by its build | Audited compatibility where the existing demuxer exposes the unsupported mapping | Family 2 packet reconstruction is understood by the adapter, but its public Ogg mapping is not implemented. Chained/multiplexed family 255 containers require independent presentation state and are explicitly rejected. No claim of complete arbitrary Opus-container parity | | HE-AAC / SBR / parametric stereo | FFmpeg full reconstruction where supported | **FFmpeg compatibility retained** | Explicit and sync-extension configurations select compatibility. Patched existing AAC decoder also catches in-band SBR after validating the complete raw-data block; it never publishes the former half-rate AAC-LC-only result. Truncated SBR payloads do not select compatibility | -| Speex NB/WB/UWB waveforms | FFmpeg Speex decode | **FFmpeg compatibility retained**; Rust Ogg framing/metadata validation | Single, all-Speex chained, all-Speex multiplexed and chained multiplexed fixture containers accepted, every logical stream validated (≤64 streams). Mixed-codec containers are rejected. Independent FFmpeg itself reports two decode/timestamp errors on the chained fixture; complete chained waveform parity is not claimed | +| Speex NB/WB/UWB waveforms | FFmpeg Speex decode | **FFmpeg compatibility retained**; Rust Ogg framing/metadata validation | Single, all-Speex chained, all-Speex multiplexed and chained multiplexed fixture containers accepted, every logical stream validated (≤64 streams). Mixed-codec containers are rejected. Debian FFmpeg 5.1 requires explicit mono/stereo layout negotiation before its waveform filter; both channel counts are covered. Independent FFmpeg itself reports two decode/timestamp errors on the chained fixture; complete chained waveform parity is not claimed | | AC-3 and other uncovered audio codecs | FFmpeg codecs installed on the host | **FFmpeg compatibility retained** | AC-3 fixture and precise unsupported-error routing. Generic malformed packet/container, resource, I/O, cancellation and decoder-internal errors cannot trigger fallback | | Small vector/text PDF first page | `pdftoppm`, `mutool` or `qlmanage` where installed, else SVG | Hayro in Rust, embedded standard fonts | Original vector/text fixtures and a Flate-compressed vector fixture render correctly; compressed old-Poppler output independently confirms the former capability. Aggregate source/expanded content ≤2 MiB, flat page tree ≤4,096 objects, syntax/numeric/depth/work limits, decompression checksum/trailing-data validation | | Inline-image, nested-resource, embedded-font, encrypted or other complex PDFs | Some documents rendered by an available old external renderer; otherwise SVG | **SVG preview; original PDF retained** | A valid inline RGB image fixture is verified separately with old Poppler. Current gate rejects inline image operators, decompression bombs, recursive/indirect trees and unbudgeted resources before Hayro. This is a remaining preview capability regression, not full PDF parity | @@ -65,10 +65,10 @@ docker build --build-arg CARGO_BUILD_JOBS=2 -t rustchan-media-validation:local . ``` - The integrated no-tools workspace run passed **2,619** tests: **1,306** in each library/CLI test binary, five CLI integration tests, one UI fixture test and one rustdoc test. Seven Python update-packaging tests also passed. Formatting, check, canonical strict Clippy, documentation, locked dependency policy and release builds passed. -- A process-spy executable recorded **zero invocations** for all 16 Rust audio tests. The worker routing test recorded exactly seven `-version` capability checks for the seven uncovered audio fixtures, and no codec command. Repeating that test with the installed FFmpeg build containing Speex passed all seven real compatibility waveform paths. A separately built validation FFmpeg without Speex failed this check, demonstrating that fallback still requires the actual codec rather than merely an executable. +- A process-spy executable recorded **zero invocations** for all 16 Rust audio tests. The worker routing test recorded exactly eight `-version` capability checks for the eight uncovered audio fixtures, and no codec command. Repeating that test with the installed FFmpeg build containing Speex passed all eight real compatibility waveform paths. A separately built validation FFmpeg without Speex failed this check, demonstrating that fallback still requires the actual codec rather than merely an executable. - Local Chromium and Chromium without JavaScript passed the media/upload/error suites: **14 tests passed, four intentional JavaScript-only skips**, including 55 JavaScript and 54 no-JavaScript upload-validation scenarios and 14 common audio formats per browser context. The separate restart suite passed **four** tests, and the opt-in real FFmpeg toolchain suite passed **one** test. Browser tests remain local; no hosted browser Actions were introduced. -- The final Linux runtime ran as UID **10001**, with `ffmpeg` and `ffprobe` physically absent from executable search paths and the saved FFmpeg file mode `000`. **42** real upload → processing → preview → download journeys passed across JavaScript and no-JavaScript contexts. These included all 14 audio fixtures, rotated/alpha HEIC, simple/compressed/unsupported PDF and both converted and maximum-loop GIFs. Original unsupported PDFs and the unrepresentable-loop GIF were retained byte for byte. Independent Pillow checks read generated previews and every animation frame; no FFmpeg participates in these production paths. -- Separately enabled Docker FFmpeg passed two real MP4 → VP9/Opus WebM browser journeys, with WebP thumbnails and stale-original redirects where the conversion raced the first response. Two actual Docker restart-policy journeys verified replacement process identities, readiness, saved settings application, JavaScript reconnect and no-JavaScript recovery. The runtime uses Debian FFmpeg **5.1.9** for the explicitly retained compatibility/video backend. +- The final Linux runtime ran as UID **10001**, with `ffmpeg` and `ffprobe` physically absent from executable search paths and the saved FFmpeg file mode `000`. **42** real upload → processing → preview → download journeys passed across JavaScript and no-JavaScript contexts. These included all 14 common audio fixtures, rotated/alpha HEIC, simple/compressed/unsupported PDF and both converted and maximum-loop GIFs. Original unsupported PDFs and the unrepresentable-loop GIF were retained byte for byte. Independent Pillow checks read generated previews and every animation frame; no FFmpeg participates in these production paths. +- Separately enabled Docker FFmpeg passed two real MP4 → VP9/Opus WebM browser journeys, with WebP thumbnails and stale-original redirects where the conversion raced the first response. Two actual Docker restart-policy journeys verified replacement process identities, readiness, saved settings application, JavaScript reconnect and no-JavaScript recovery. Separate uploads also verify the eight unsupported-audio compatibility fixtures in both browser contexts. The runtime uses Debian FFmpeg **5.1.9** for the explicitly retained compatibility/video backend. - Synthetic fixtures and independent PCM reference files are checked in under `tests/fixtures/media`. Private browser data, candidate experiments, screenshots, logs and preserved runnable binaries are kept outside `target` and outside the Git deliverable. The final handoff gives their location and the exact commit, image and required-CI results. -Native systemd restart operation remains unverified on this macOS host; the previous owner's managed-Linux tests used mocked service control. Windows and configured Linux/macOS build coverage depends on the required GitHub platform checks and is reported separately. Full HE-AAC/Speex reconstruction, arbitrary video thumbnails and complex PDF rendering remain the precise functional limits above; a successful test run does not establish the requested WebM-only FFmpeg boundary. +Native systemd restart operation remains unverified on this macOS host; the previous owner's managed-Linux tests used mocked service control. The first required Windows build exposed an unused Unix updater helper in the restart handoff; matching its definition to its existing Unix caller fixes the build without relaxing lints. Configured Windows/Linux/macOS build coverage is reported in the final required GitHub platform checks. Full HE-AAC/Speex reconstruction, arbitrary video thumbnails and complex PDF rendering remain the precise functional limits above; a successful test run does not establish the requested WebM-only FFmpeg boundary. diff --git a/src/config/admin.rs b/src/config/admin.rs index 8197450d..042670d2 100644 --- a/src/config/admin.rs +++ b/src/config/admin.rs @@ -1239,6 +1239,7 @@ fn pending_from( /// /// # Errors /// Rejects invalid saved or previously initialized configuration. +#[cfg(unix)] pub(crate) fn restart_files_differ(previous: &str, candidate: &str) -> anyhow::Result { let active = resolve_file(previous, &Environment::Process)?; pending_from(candidate, &active, &Environment::Process) diff --git a/src/workers/mod.rs b/src/workers/mod.rs index c6a81a07..417ab139 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -1858,8 +1858,23 @@ async fn render_waveform_with_compatibility( .checked_sub(started.elapsed()) .ok_or_else(|| anyhow::anyhow!("waveform deadline exceeded"))?; let mut command = ffmpeg_command(); + // Debian FFmpeg's Speex decoder leaves frame layouts unspecified. Its + // validated header permits only mono/stereo; negotiate either without + // downmixing or changing the channel count before showwavespic. + let layout = if error.chain().any(|cause| { + matches!( + cause.downcast_ref::(), + Some(symphonia::core::errors::Error::Unsupported( + "Speex requires the compatibility decoder" + )) + ) + }) { + "aformat=channel_layouts=mono|stereo," + } else { + "" + }; let filter = format!( - "showwavespic=s={}x{}:colors=0x888888", + "{layout}showwavespic=s={}x{}:colors=0x888888", CONFIG.thumb_size, CONFIG.thumb_size / 2 ); @@ -3192,6 +3207,7 @@ mod tests { "he-aac.m4a", "he-aac-inband.aac", "tone.spx", + "speex-stereo.spx", "speex-chained.spx", "speex-multiplexed.spx", "speex-chained-multiplexed.spx", diff --git a/tests/fixtures/media/README.md b/tests/fixtures/media/README.md index 12c276ae..05b75b5d 100644 --- a/tests/fixtures/media/README.md +++ b/tests/fixtures/media/README.md @@ -27,3 +27,5 @@ The `opus-surround-distinct.opus` fixture contains six generated sine waves at 2 `inline-image.pdf` is an original 100 × 80 page with one red RGB pixel displayed as a 60 × 60 inline image. It verifies that valid input outside the bounded renderer subset keeps its original download and receives the deterministic SVG preview. `speex-chained-multiplexed.spx` joins two complete multiplexed groups with four distinct serials and recomputed CRCs. Completed logical streams must not block the next chain’s grouped BOS pages. + +`speex-stereo.spx` is Debian FFmpeg 5.1.9/libspeex encoding of the synthetic stereo `tone.m4a`, resampled to 32 kHz (`-ar 32000 -ac 2 -c:a libspeex`). Together with mono `tone.spx`, it protects count-preserving mono/stereo layout negotiation before the legacy Debian waveform filter. diff --git a/tests/fixtures/media/speex-stereo.spx b/tests/fixtures/media/speex-stereo.spx new file mode 100644 index 0000000000000000000000000000000000000000..39f200b4f0474ad4706c1bcb63b8ec2c078de67a GIT binary patch literal 910 zcmeZIPY-5bVt@kG`Z6C7Gt)`TfH5GrAT_l@K|#S#&q&V@rUXhe0_gw{yOsg0odw7Q z8MC06fuR9v9!L!`@CVri2OzsZD%d|tJ!L$}3uO5umZh0m>KU2q85$U{0|j`1I43bL zy)-dB)wVP*MH0x*NX$#gNiB-cOUzBR4bBIOdO$?PfRbgYc`5lt@tG;M(FU<_BnOm9 z%}dTtNiDJk+K~*j!x(IbLQZB zZQ8S)lCkqniikYwY3}C5Y1o2e9$>?OF1yng7xJi@KW%pls} zNh!9t%~EXOkPwj(Ve<5KKH}oAU#!2auR9j*viQZl5oYaWhuGPpdx5bz=l%5tdjPtH BO3?rS literal 0 HcmV?d00001 From 39675daf92cceb4cf8a7159616cb513b7d7c6931 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 11:33:10 -0700 Subject: [PATCH 06/11] Harden AV1 WebM conversion and remove FFmpeg WebP dependencies --- README.md | 2 +- SETUP.md | 28 +- docs/non-video-media-migration.md | 9 +- docs/public-interface-polish-audit.md | 3 + docs/software-update-ui-release-audit.md | 3 + src/detect.rs | 97 ++---- src/handlers/admin/mod.rs | 55 ++-- src/handlers/board/create_thread.rs | 2 - src/handlers/mod.rs | 11 - src/handlers/posting.rs | 11 - src/handlers/thread.rs | 2 - src/media/audio.rs | 2 +- src/media/audio_mp4.rs | 135 ++++++++- src/media/ffmpeg.rs | 283 +++++++++++++----- src/media/heif.rs | 1 - src/media/images/tests.rs | 5 +- src/media/mod.rs | 39 +-- src/media/probe.rs | 364 ++++++++++++++++++++++- src/media/thumbnail.rs | 140 ++------- src/middleware/state.rs | 4 +- src/server/console/operator_views.rs | 8 +- src/server/console/telemetry.rs | 8 +- src/server/server.rs | 8 +- src/templates/admin.rs | 37 ++- src/templates/admin/maintenance.rs | 18 +- src/templates/admin/site_health.rs | 10 +- src/test_fixtures.rs | 2 +- src/utils/files.rs | 4 + src/utils/files/storage.rs | 46 ++- src/workers/mod.rs | 214 ++++++++----- tests/e2e/README.md | 5 +- tests/e2e/auto-compress.spec.ts | 10 +- tests/e2e/media-toolchain-check.mjs | 30 +- tests/e2e/media-toolchain.spec.ts | 49 +++ tests/fixtures/media/README.md | 9 + tests/fixtures/media/av1.mkv | Bin 0 -> 2406 bytes tests/fixtures/media/av1.mp4 | Bin 0 -> 2650 bytes tests/fixtures/media/av1.webm | Bin 0 -> 2352 bytes tests/fixtures/media/vp8.webm | Bin 0 -> 3135 bytes 39 files changed, 1163 insertions(+), 491 deletions(-) create mode 100644 tests/fixtures/media/av1.mkv create mode 100644 tests/fixtures/media/av1.mp4 create mode 100644 tests/fixtures/media/av1.webm create mode 100644 tests/fixtures/media/vp8.webm diff --git a/README.md b/README.md index eb4d7267..4f3efeb2 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ Common settings: | `port` | The HTTP port; normally `8080` | | `enable_tor_support` | Whether the built-in Tor onion service runs | | `tor_only` | Whether the site is served through Tor with a local-only listener | -| `require_ffmpeg` | Whether startup requires the enhanced media tools | +| `require_ffmpeg` | Whether startup requires FFmpeg for video and uncovered audio codecs | | `auto_full_backup_interval_hours` | How often automatic full-site backups run | | `backup_directory` | An optional absolute path for saved backups | | `[tls].enabled` | Whether the app's built-in HTTPS listener runs | diff --git a/SETUP.md b/SETUP.md index 385cca90..b2764d11 100644 --- a/SETUP.md +++ b/SETUP.md @@ -145,34 +145,36 @@ require_ffmpeg = true ## Verify WebP and WebM Support -RustChan checks more than just whether `ffmpeg` exists. It also checks whether your build includes: +WebP images, GIF → animated WebP, and animated WebP validation are built into RustChan through Rust crates and remain available without FFmpeg. Video thumbnails use FFmpeg to extract a PNG frame and Rust to encode WebP. + +For video/WebM conversion, RustChan checks the installed decoder, encoder and muxer lists independently: -- `libwebp` for WebP image thumbnails and conversions - `libvpx-vp9` for WebM video encoding - `libopus` for WebM audio encoding +- the `webm` muxer for output +- AV1 decoders for AV1 inputs (AV1 encoder availability is reported separately; output remains VP9 + Opus) Use these commands: ```bash -ffmpeg -encoders | rg libwebp ffmpeg -encoders | rg libvpx-vp9 ffmpeg -encoders | rg libopus +ffmpeg -muxers | rg webm +ffmpeg -decoders | rg av1 ``` If you do not have `rg`, use: ```bash -ffmpeg -encoders | grep libwebp ffmpeg -encoders | grep libvpx-vp9 ffmpeg -encoders | grep libopus ``` -You want all three to appear. +WebM conversion needs both selected encoders and the WebM muxer. AV1 input also needs an AV1 decoder; an AV1 encoder is optional and is not selected for RustChan output. ### What Each Encoder Enables -- `libwebp`: WebP thumbnail and image conversion support -- `libvpx-vp9` + `libopus`: MP4 to WebM transcoding support +- `libvpx-vp9` + `libopus`: MP4/Matroska and WebM/AV1 to VP9/Opus WebM conversion. Compatible VP8/VP9 WebM with Opus/Vorbis audio is preserved. ### Linux Notes @@ -180,7 +182,7 @@ On Debian-family systems, the usual install is: ```bash sudo apt update -sudo apt install -y ffmpeg libwebp-dev libvpx-dev libopus-dev +sudo apt install -y ffmpeg libvpx-dev libopus-dev ``` The important part is still the actual `ffmpeg -encoders` output. Package names alone do not guarantee your installed FFmpeg binary was built with every encoder enabled. @@ -190,7 +192,7 @@ The important part is still the actual `ffmpeg -encoders` output. Package names Most Homebrew FFmpeg installs are fine, but verify with: ```bash -ffmpeg -encoders | rg 'libwebp|libvpx-vp9|libopus' +ffmpeg -encoders | rg 'libvpx-vp9|libopus' ``` If one is missing, reinstall FFmpeg from a build source that includes that codec set. @@ -200,7 +202,6 @@ If one is missing, reinstall FFmpeg from a build source that includes that codec Use a full FFmpeg build rather than a minimal one, then verify with: ```powershell -ffmpeg -encoders | Select-String libwebp ffmpeg -encoders | Select-String libvpx-vp9 ffmpeg -encoders | Select-String libopus ``` @@ -209,8 +210,9 @@ ffmpeg -encoders | Select-String libopus RustChan will log warnings and continue: -- missing `libwebp`: image thumbnails stay in original-friendly formats where needed -- missing VP9 or Opus: MP4 uploads are stored as MP4 instead of transcoded to WebM +- missing VP9, Opus or the WebM muxer: video uploads retain their originals +- missing an AV1 decoder: AV1 conversion fails with a useful diagnostic and retains the original +- missing FFmpeg: Rust-native still and animated WebP continue to work These warnings appear in the console at startup and in `rustchan-data/logs/`. @@ -655,7 +657,7 @@ Run: ```bash ffmpeg -version -ffmpeg -encoders | rg 'libwebp|libvpx-vp9|libopus' +ffmpeg -encoders | rg 'libvpx-vp9|libopus' ``` If one of those encoders is missing, RustChan will still run but some media features will be downgraded. diff --git a/docs/non-video-media-migration.md b/docs/non-video-media-migration.md index 4b5e9171..4e88a8d4 100644 --- a/docs/non-video-media-migration.md +++ b/docs/non-video-media-migration.md @@ -1,5 +1,8 @@ # Non-video media backend migration +Current video pipeline note (2026-10-02): FFmpeg extracts PNG video frames; Rust encodes their WebP thumbnails. FFmpeg WebP encoder detection and installation requirements have been removed. WebM conversion retains VP9 + Opus with independent AV1 decoder/encoder diagnostics. Historical validation results below describe the implementation at the time of that audit. + + > Historical report for the initial migration at `16f4b91`. The current [capability matrix and follow-up evidence](media-capabilities.md) supersede the uncommitted status, Opus compatibility limitations and unavailable Docker-base conclusions below. Remaining compatibility calls and PDF limitations are still explicit. Implemented backend migration, with the explicitly authorized FFmpeg compatibility fallback for uncovered audio codecs/variants. All changes remain uncommitted. Rust 1.99 is now the minimum in Cargo, CI, release builds, Docker and setup documentation. Actual video frame decoding and VP9/Opus WebM transcoding remain on FFmpeg. @@ -14,7 +17,7 @@ Implemented backend migration, with the explicitly authorized FFmpeg compatibili | Same helper with GIF input | IMAGE | Animated WebP, composited frames, delays, transparency and repeat behavior | Existing `image` GIF compositor and lossless WebP encoder, bounded animation container writer | Decode every output frame and compare pixels, delays and loops | | `ffmpeg_image_to_webp_scaled`, used by `banner.rs` | IMAGE | GIF and animated WebP banners scaled without flattening | Same animation pipeline, existing image WebP decoder | Partial frames, disposal, bounds and stored animation compatibility | | `thumbnail.rs` image branch -> `ffmpeg_thumbnail` | IMAGE | First composited frame thumbnail, alpha, aspect ratio, no upload mutation | Existing `image` decoder/resizer/encoder plus HEIC still decoder | Thumbnail dimensions/pixels with FFmpeg absent | -| `thumbnail.rs` video branch -> `ffmpeg_thumbnail` | VIDEO | Decode compressed video frame, fit thumbnail; SVG fallback on failure | Retain FFmpeg command and libwebp capability for this video operation | Existing video thumbnail tests | +| `thumbnail.rs` video branch -> `ffmpeg_thumbnail` | VIDEO | Decode compressed video frame, fit thumbnail; SVG fallback on failure | FFmpeg extracts a PNG frame; Rust encodes WebP (updated by the AV1 follow-up) | Existing video thumbnail tests | | `probe_stream_kind`, called from upload validation and worker output validation | CONTAINER PROBING | Audio/video classification; MP4/M4A, WebM/MKV, standalone audio; malformed/ambiguous handling | Symphonia ISOBMFF/Matroska/audio format parsers | Old/new stream-kind and malformed fixture comparison | | `probe_stream_kind_with_ffmpeg` -> two zero-frame stream-map commands | CONTAINER PROBING | Establish ambiguous WebM kind without decoding, neutral download if ambiguous | Same Rust container parsers; unsupported audio containers retain bounded zero-frame FFmpeg maps | Audio WebM, video WebM and ambiguous policy tests | | `probe_video_codec`, called by transcode preparation/output validation | CONTAINER PROBING | First video codec, skip existing VP9, verify generated VP9 | Same container parsers; actual video processing retained | MP4 H.264 and WebM VP9 before/after | @@ -58,7 +61,7 @@ HEIC and Opus decoders are recent focused implementations. Their selected behavi | Opus mono/stereo, voice/SILK | FFmpeg | Rust, including exact encoder-delay/end-padding handling and header gain | | Uncovered audio: AC-3, Speex, unsupported AAC profiles, mapped Opus/multistream | FFmpeg when present, otherwise placeholder | Approved FFmpeg compatibility path, selected only for unavailable formats/codecs/variants; AC-3 M4A and standard 5.1 Opus waveform output verified | | PDF | External Poppler/MuPDF/QuickLook preview when available, otherwise SVG | Hayro renders a small bounded uncompressed vector/text subset; broader or unsafe rendering uses the existing SVG without changing/rejecting the PDF download | -| Video frame thumbnails | FFmpeg/libwebp or SVG | Retained command unchanged; actual WebP video thumbnail browser pass | +| Video frame thumbnails | FFmpeg/libwebp or SVG | FFmpeg PNG extraction and Rust WebP encoding; SVG fallback on failure | | Video → WebM | FFmpeg libvpx-vp9/libopus | Retained encoder arguments, color normalization, CPU tuning, timeouts, cancellation, atomic DB/file lifecycle; actual browser transcode, WebM playback markup and stale-MP4 redirect pass | The compatibility fallback was explicitly authorized after reproducing AC-3 M4A failure in Rust and success with the original `showwavespic` command. It overrides the objective's earlier strict no-audio-FFmpeg rule. Common codecs never consult an executable to render their waveforms. @@ -79,7 +82,7 @@ Useful debug measurements on this Apple-silicon host: isolated HEIC decode was a ## Remaining process responsibilities -- `media/ffmpeg.rs::ffmpeg_thumbnail`: actual video-frame decoding and WebP preview encoding. The function body was compared with HEAD and is unchanged. +- `media/ffmpeg.rs::ffmpeg_thumbnail`: actual video-frame decoding to PNG. Rust now encodes the WebP preview; the AV1 follow-up removed FFmpeg WebP encoder detection. - `workers::transcode_video` / `media/ffmpeg.rs::build_vp9_transcode_args`: retained video WebM transcoder. Both the argument builder and CPU profile selection were compared with HEAD and are unchanged. - `workers::render_waveform_with_compatibility`: original bounded `showwavespic` command for uncovered audio only, sharing the worker deadline, process-group shutdown and atomic publication. - `media/ffmpeg.rs::probe_uncovered_audio`: bounded zero-frame stream maps only after an unsupported audio container/parser result, preserving accepted codec/container variants. diff --git a/docs/public-interface-polish-audit.md b/docs/public-interface-polish-audit.md index 8e47116b..bde29ad8 100644 --- a/docs/public-interface-polish-audit.md +++ b/docs/public-interface-polish-audit.md @@ -1,5 +1,8 @@ # Public interface polish audit — 2026-09-30 +Current video pipeline note (2026-10-02): FFmpeg extracts PNG video frames; Rust encodes their WebP thumbnails. FFmpeg WebP encoder detection and installation requirements have been removed. WebM conversion retains VP9 + Opus with independent AV1 decoder/encoder diagnostics. Historical validation results below describe the implementation at the time of that audit. + + Scope: home, board indexes, catalog, threads, search, archives, preferences, media, and polls. Admin changes were excluded; shared behavior was checked with the maintained regression harness. ChanNet is already removed from this branch; no obsolete infrastructure was restored. The initial `interface-audit` working tree was clean. At the audit handoff, all changes were local and uncommitted. No dependencies, framework, route renames, deployment, or visual redesign were introduced. diff --git a/docs/software-update-ui-release-audit.md b/docs/software-update-ui-release-audit.md index b64cc398..bcc5e732 100644 --- a/docs/software-update-ui-release-audit.md +++ b/docs/software-update-ui-release-audit.md @@ -1,5 +1,8 @@ # Software Updates UI and v1.6.0 release audit +Current video pipeline note (2026-10-02): FFmpeg extracts PNG video frames; Rust encodes their WebP thumbnails. FFmpeg WebP encoder detection and installation requirements have been removed. WebM conversion retains VP9 + Opus with independent AV1 decoder/encoder diagnostics. Historical validation results below describe the implementation at the time of that audit. + + Audit date: 2026-10-01. Candidate repository: `csd113/RustChan`. ## Prerequisite and scope diff --git a/src/detect.rs b/src/detect.rs index 80441b74..d10a8f46 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -22,13 +22,15 @@ pub struct WebmEncoderStatus { pub vp9: bool, /// Whether the `libopus` audio encoder is available. pub opus: bool, + /// Whether the `WebM` output muxer is available. + pub muxer: bool, } impl WebmEncoderStatus { - /// Returns whether both required `WebM` encoders are available. + /// Returns whether both selected encoders and the `WebM` muxer are available. #[must_use] pub const fn is_available(self) -> bool { - self.vp9 && self.opus + self.vp9 && self.opus && self.muxer } } @@ -67,76 +69,12 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { } } -/// Probe whether the detected ffmpeg has `libwebp` compiled in. -pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { - if !ffmpeg_ok { - return false; - } - - let has_webp = crate::media::ffmpeg::check_webp_encoder(); - - if has_webp { - tracing::info!( - target: "rustchan::detect", - webp = true, - "ffmpeg libwebp encoder available — video-frame thumbnails enabled" - ); - } else { - tracing::warn!( - target: "rustchan::detect", - webp = false, - "ffmpeg libwebp encoder missing — video-frame thumbnails use placeholders" - ); - if crate::logging::is_tty() { - crate::logging::console_print_raw(&webp_install_hint()); - } - } - - has_webp -} - /// Reports the built-in pure-Rust PDF preview renderer. #[must_use] pub fn detect_pdf_thumbnail_renderers() -> Vec { crate::media::thumbnail::detect_pdf_renderers() } -/// Builds the platform-specific `WebP` encoder installation hint. -fn webp_install_hint() -> String { - let mut s = String::new(); - - #[cfg(target_os = "macos")] - { - s.push_str( - " ── macOS: reinstall ffmpeg with libwebp ─────────────────────────────\n\ - \n\ - \x1b[2m brew uninstall ffmpeg\n\ - \x1b[2m brew tap homebrew-ffmpeg/ffmpeg\n\ - \x1b[2m brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-webp\x1b[0m\n\n", - ); - } - #[cfg(target_os = "linux")] - { - s.push_str( - " ── Linux: install ffmpeg with libwebp ───────────────────────────────\n\ - \n\ - \x1b[2m sudo apt update && sudo apt install ffmpeg libwebp-dev\x1b[0m\n\n", - ); - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - s.push_str( - " Reinstall ffmpeg with libwebp support. See: https://ffmpeg.org/download.html\n\n", - ); - } - - if !crate::logging::is_tty() { - // Strip any ANSI codes we added for TTY mode - s.retain(|c| c != '\x1b'); - } - s -} - /// Probes an external tool using its conventional version argument. fn probe_tool(program: &str) -> bool { probe_tool_with_args(program, &["-version"]) @@ -175,14 +113,23 @@ pub fn detect_webm_encoder(ffmpeg_ok: bool) -> WebmEncoderStatus { return WebmEncoderStatus { vp9: false, opus: false, + muxer: false, }; } - let has_vp9 = crate::media::ffmpeg::check_vp9_encoder(); - let has_opus = crate::media::ffmpeg::check_opus_encoder(); + let caps = crate::media::ffmpeg::video_capabilities(); + tracing::info!( + target: "rustchan::detect", + av1_decoders = ?caps.av1.decoders, + av1_encoders = ?caps.av1.encoders, + "ffmpeg AV1 build capabilities; RustChan output policy remains VP9 + Opus" + ); + let has_vp9 = caps.vp9; + let has_opus = caps.opus; let status = WebmEncoderStatus { vp9: has_vp9, opus: has_opus, + muxer: caps.webm_muxer, }; if status.is_available() { @@ -203,10 +150,15 @@ pub fn detect_webm_encoder(ffmpeg_ok: bool) -> WebmEncoderStatus { target: "rustchan::detect", vp9 = has_vp9, opus = has_opus, - "ffmpeg VP9/Opus encoders missing — MP4 uploads stored as MP4" + webm_muxer = caps.webm_muxer, + "ffmpeg VP9/Opus encoders or WebM muxer missing — videos stored as uploaded" ); if crate::logging::is_tty() { - crate::logging::console_print_raw(&webm_install_hint(has_vp9, has_opus)); + crate::logging::console_print_raw(&webm_install_hint( + has_vp9, + has_opus, + caps.webm_muxer, + )); } } @@ -214,7 +166,7 @@ pub fn detect_webm_encoder(ffmpeg_ok: bool) -> WebmEncoderStatus { } /// Builds a platform-specific installation hint for missing `WebM` encoders. -fn webm_install_hint(has_vp9: bool, has_opus: bool) -> String { +fn webm_install_hint(has_vp9: bool, has_opus: bool, has_muxer: bool) -> String { let mut s = String::new(); if !has_vp9 { s.push_str(" Missing: libvpx-vp9 (VP9 video encoder)\n"); @@ -222,6 +174,9 @@ fn webm_install_hint(has_vp9: bool, has_opus: bool) -> String { if !has_opus { s.push_str(" Missing: libopus (Opus audio encoder)\n"); } + if !has_muxer { + s.push_str(" Missing: webm (WebM output muxer)\n"); + } s.push('\n'); #[cfg(target_os = "macos")] diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index 20e8f8e2..a342b87d 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -616,7 +616,7 @@ struct AdminPanelSnapshot { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffmpeg_webp_available: bool, + ffmpeg_av1: crate::media::ffmpeg::Av1Capabilities, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, ffmpeg_opus_available: bool, @@ -815,7 +815,7 @@ struct MaintenanceDomainData { media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, ffmpeg_available: bool, - ffmpeg_webp_available: bool, + ffmpeg_av1: crate::media::ffmpeg::Av1Capabilities, ffmpeg_vp9_available: bool, ffmpeg_vp9_encoder_available: bool, ffmpeg_opus_available: bool, @@ -1306,7 +1306,7 @@ fn load_maintenance_domain_data( media_auto_prune_enabled: db::get_media_auto_prune_enabled(conn), media_max_active_content_size_bytes: db::get_media_max_active_content_size_bytes(conn), ffmpeg_available: state.ffmpeg_available, - ffmpeg_webp_available: state.ffmpeg_webp_available, + ffmpeg_av1: state.ffmpeg_av1.clone(), ffmpeg_vp9_available: state.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: state.ffmpeg_vp9_encoder_available, ffmpeg_opus_available: state.ffmpeg_opus_available, @@ -1385,7 +1385,7 @@ fn load_admin_panel_snapshot( media_max_active_content_size_bytes: maintenance_domain .media_max_active_content_size_bytes, ffmpeg_available: maintenance_domain.ffmpeg_available, - ffmpeg_webp_available: maintenance_domain.ffmpeg_webp_available, + ffmpeg_av1: maintenance_domain.ffmpeg_av1, ffmpeg_vp9_available: maintenance_domain.ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: maintenance_domain.ffmpeg_vp9_encoder_available, ffmpeg_opus_available: maintenance_domain.ffmpeg_opus_available, @@ -1662,16 +1662,16 @@ fn dashboard_dependency_status( ffmpeg_required: bool, ) -> (String, String, crate::templates::AdminDashboardState) { let ffmpeg = detection_word(maintenance.ffmpeg_available); - let state = if maintenance.ffmpeg_available { + let state = if maintenance.ffmpeg_vp9_available { crate::templates::AdminDashboardState::Ok - } else if ffmpeg_required { + } else if ffmpeg_required && !maintenance.ffmpeg_available { crate::templates::AdminDashboardState::ActionNeeded } else { crate::templates::AdminDashboardState::Informational }; - let status = if maintenance.ffmpeg_available { + let status = if maintenance.ffmpeg_vp9_available { "ready" - } else if ffmpeg_required { + } else if ffmpeg_required && !maintenance.ffmpeg_available { "required tool missing" } else { "limited" @@ -1679,10 +1679,11 @@ fn dashboard_dependency_status( ( status.to_owned(), format!( - "ffmpeg {ffmpeg}; WebP {}; VP9 {}; Opus {}.", - detection_word(maintenance.ffmpeg_webp_available), + "WebP/images built in (Rust); ffmpeg video {ffmpeg}; VP9 {}; Opus {}; AV1 decoding {}; AV1 encoding {} (output policy: VP9 + Opus).", detection_word(maintenance.ffmpeg_vp9_encoder_available), - detection_word(maintenance.ffmpeg_opus_available) + detection_word(maintenance.ffmpeg_opus_available), + detection_word(!maintenance.ffmpeg_av1.decoders.is_empty()), + detection_word(!maintenance.ffmpeg_av1.encoders.is_empty()) ), state, ) @@ -1913,11 +1914,8 @@ fn render_admin_panel_from_snapshot( } else { crate::templates::AdminDetectionStatus::Missing }, - webp_encoder: if snapshot.ffmpeg_webp_available { - crate::templates::AdminDetectionStatus::Detected - } else { - crate::templates::AdminDetectionStatus::Missing - }, + av1_decoder: detection_status(!snapshot.ffmpeg_av1.decoders.is_empty()), + av1_encoder: detection_status(!snapshot.ffmpeg_av1.encoders.is_empty()), vp9_pipeline: if snapshot.ffmpeg_vp9_available { crate::templates::AdminDetectionStatus::Detected } else { @@ -1995,9 +1993,10 @@ fn build_site_health_view<'a>( tor_detail: &snapshot.dashboard.tor_detail, dependency_summary: crate::templates::AdminSiteHealthDependencySummary { ffmpeg: detection_status(snapshot.ffmpeg_available), - webp: detection_status(snapshot.ffmpeg_webp_available), vp9: detection_status(snapshot.ffmpeg_vp9_encoder_available), opus: detection_status(snapshot.ffmpeg_opus_available), + av1_decoder: detection_status(!snapshot.ffmpeg_av1.decoders.is_empty()), + av1_encoder: detection_status(!snapshot.ffmpeg_av1.encoders.is_empty()), }, running_jobs: snapshot.site_health.running_jobs, queued_jobs: snapshot.site_health.queued_jobs, @@ -2039,7 +2038,10 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st Database schema: {schema}\n\ OS: {os}-{arch}\n\ SQLite: {sqlite}\n\ - ffmpeg: {ffmpeg}\n\ + WebP/images: built in (Rust), including animation\n\ + ffmpeg video: {ffmpeg}\n\ + WebM conversion (VP9 + Opus): {webm}\n\ + AV1 decoding: {av1_decoding}; AV1 encoding: {av1_encoding}\n\ Tor enabled: {tor_enabled} ({tor_detail})\n\ TLS enabled: {tls_enabled}\n\ Reverse proxy: {reverse_proxy}\n\ @@ -2053,6 +2055,9 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st arch = std::env::consts::ARCH, sqlite = rusqlite::version(), ffmpeg = detection_word(snapshot.ffmpeg_available), + webm = detection_word(snapshot.ffmpeg_vp9_available), + av1_decoding = detection_word(!snapshot.ffmpeg_av1.decoders.is_empty()), + av1_encoding = detection_word(!snapshot.ffmpeg_av1.encoders.is_empty()), warnings = indent_diagnostics_block(&snapshot.site_health.recent_warnings), ) } @@ -2754,7 +2759,7 @@ mod tests { media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, ffmpeg_available: ffmpeg, - ffmpeg_webp_available: false, + ffmpeg_av1: crate::media::ffmpeg::Av1Capabilities::default(), ffmpeg_vp9_available: false, ffmpeg_vp9_encoder_available: false, ffmpeg_opus_available: false, @@ -2765,6 +2770,9 @@ mod tests { #[test] fn dashboard_optional_media_tools_are_informational_not_warning() { let maintenance = maintenance_with_media_tools(false); + let detail = dashboard_dependency_status(&maintenance, false).1; + assert!(detail.contains("WebP/images built in (Rust)")); + assert!(detail.contains("AV1 decoding missing; AV1 encoding missing")); assert_eq!( dashboard_dependency_status(&maintenance, false).2, @@ -2776,6 +2784,15 @@ mod tests { ); } + #[test] + fn admin_av1_status_distinguishes_decoding_from_encoding() { + let mut maintenance = maintenance_with_media_tools(true); + maintenance.ffmpeg_av1.decoders.push("libdav1d".to_owned()); + let detail = dashboard_dependency_status(&maintenance, false).1; + assert!(detail.contains("AV1 decoding found; AV1 encoding missing")); + assert!(detail.contains("output policy: VP9 + Opus")); + } + #[test] fn dashboard_tor_bootstrap_is_pending_and_disabled_is_neutral() { assert_eq!( diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index d1c7e388..17b36d4e 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -130,7 +130,6 @@ pub(in crate::server) async fn create_thread( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is // dropped. Keep the permit inside this closure so a disconnected @@ -164,7 +163,6 @@ pub(in crate::server) async fn create_thread( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffmpeg_webp_available, }, ) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index eed4192e..a46097ce 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1274,7 +1274,6 @@ pub(crate) fn process_primary_upload( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> Result<(Option, Option)> { let Some((upload, fname)) = file_data else { return Ok((None, None)); @@ -1341,7 +1340,6 @@ pub(crate) fn process_primary_upload( max_audio_size, max_pdf_size, ffmpeg_available, - ffmpeg_webp_available, allow_any_files, }; let validated = crate::utils::files::storage::validate_upload_for_storage( @@ -1499,7 +1497,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size: usize, max_pdf_size: usize, ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> Result<( Option, Option, @@ -1521,7 +1518,6 @@ pub(crate) fn process_audio_first_uploads( max_audio_size, max_pdf_size, ffmpeg_available, - ffmpeg_webp_available, ) }; @@ -2507,7 +2503,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ); let Err(error) = result else { @@ -2570,7 +2565,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ); let Err(error) = result else { @@ -2636,7 +2630,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; @@ -2677,7 +2670,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ); let Err(error) = result else { @@ -2720,7 +2712,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, board.max_pdf_size_bytes(), false, - false, ); let Err(error) = result else { @@ -2759,7 +2750,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ); let Err(error) = result else { @@ -2802,7 +2792,6 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, false, - false, ) .context("accept PDF upload")?; let uploaded = uploaded.context("PDF upload result was empty")?; diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index e4ad6373..971cc4a7 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -49,7 +49,6 @@ pub(super) struct SubmitPostCommand { pub upload_dir: String, pub thumb_size: u32, pub ffmpeg_available: bool, - pub ffmpeg_webp_available: bool, } pub(super) struct SubmitPostResult { @@ -93,7 +92,6 @@ struct UploadConfig<'a> { pub max_audio_size: usize, pub max_pdf_size: usize, pub ffmpeg_available: bool, - pub ffmpeg_webp_available: bool, } #[derive(Clone)] @@ -420,7 +418,6 @@ fn process_uploads( config.max_audio_size, config.max_pdf_size, config.ffmpeg_available, - config.ffmpeg_webp_available, ); let (mut primary, mut audio, primary_hash) = match processed { @@ -530,7 +527,6 @@ pub(super) fn submit_post( upload_dir, thumb_size, ffmpeg_available, - ffmpeg_webp_available, } = command; let board = db::get_board_by_short(conn, &board_short)? @@ -615,7 +611,6 @@ pub(super) fn submit_post( max_audio_size: effective_max_audio_size, max_pdf_size: effective_max_pdf_size, ffmpeg_available, - ffmpeg_webp_available, }, )?; let deletion_token = resolve_deletion_token(&deletion_token); @@ -917,7 +912,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffmpeg_webp_available: false, } } @@ -954,7 +948,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffmpeg_webp_available: false, } } @@ -987,7 +980,6 @@ mod tests { upload_dir: upload_dir.to_owned(), thumb_size: 250, ffmpeg_available: false, - ffmpeg_webp_available: false, } } @@ -1504,7 +1496,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffmpeg_webp_available: false, }, ); @@ -1917,7 +1908,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffmpeg_webp_available: false, }, ) .context("failed to process deduplicated upload")?; @@ -2003,7 +1993,6 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffmpeg_webp_available: false, }, )?; diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index f87dbdef..2c3abefe 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -396,7 +396,6 @@ pub(in crate::server) async fn post_reply( let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&state.job_queue); let ffmpeg_available = state.ffmpeg_available; - let ffmpeg_webp_available = state.ffmpeg_webp_available; move || -> Result { // `spawn_blocking` work is not cancelled when its join handle is // dropped. Keep the permit inside this closure so a disconnected @@ -425,7 +424,6 @@ pub(in crate::server) async fn post_reply( upload_dir: CONFIG.upload_dir.clone(), thumb_size: CONFIG.thumb_size, ffmpeg_available, - ffmpeg_webp_available, }, ) } diff --git a/src/media/audio.rs b/src/media/audio.rs index 615caf5b..af8079d6 100644 --- a/src/media/audio.rs +++ b/src/media/audio.rs @@ -3,7 +3,7 @@ use anyhow::{ensure, Context as _, Result}; use image::{Rgba, RgbaImage}; #[path = "audio_mp4.rs"] -mod mp4; +pub(super) mod mp4; #[path = "audio_opus.rs"] mod opus; use std::path::Path; diff --git a/src/media/audio_mp4.rs b/src/media/audio_mp4.rs index f3fb85a0..46b9a767 100644 --- a/src/media/audio_mp4.rs +++ b/src/media/audio_mp4.rs @@ -1,4 +1,4 @@ -//! Bounded MP4 edit-list timing missing from Symphonia's current demuxer. +//! Bounded MP4 metadata supplements: edit timing and video codec declarations. use anyhow::{ensure, Context as _, Result}; use std::fs::File; @@ -209,6 +209,73 @@ fn one(atoms: &[Atom], kind: [u8; 4]) -> Result { Ok(atom) } +/// Recover a declared AV1/VP8/VP9 sample-entry codec for one validated MP4 track. +/// Symphonia 0.6 recognizes these entries but leaves their codec ID unset. +/// Walk structural boxes by track ID; never scan media payloads for codec tags. +pub(in crate::media) fn declared_video_codec( + path: &Path, + track_id: u32, + check: impl FnMut() -> Result<()>, +) -> Result> { + let file = File::open(path)?; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "MP4 codec source is not a regular file"); + let mut reader = Reader { + file, + boxes: 0, + check, + }; + let root = reader.children(0, metadata.len())?; + let moov = one(&root, *b"moov")?; + let movie = reader.children(moov.body, moov.end)?; + let mut chosen = None; + for track in movie.iter().filter(|atom| atom.kind == *b"trak") { + let children = reader.children(track.body, track.end)?; + let tkhd = one(&children, *b"tkhd")?; + let offset = reader.timestamp_offset(tkhd)?; + if u32::from_be_bytes(reader.field::<4>(tkhd, offset)?) == track_id { + ensure!(chosen.is_none(), "duplicate MP4 video track identifier"); + chosen = Some(children); + } + } + let mut children = chosen.context("MP4 video codec track missing")?; + for kind in [*b"mdia", *b"minf", *b"stbl"] { + let parent = one(&children, kind)?; + children = reader.children(parent.body, parent.end)?; + } + let stsd = one(&children, *b"stsd")?; + ensure!( + reader.field::<1>(stsd, 0)? == [0], + "invalid MP4 sample description version" + ); + ensure!( + u32::from_be_bytes(reader.field::<4>(stsd, 4)?) == 1, + "ambiguous MP4 video sample descriptions" + ); + let entries = reader.children( + stsd.body + .checked_add(8) + .context("MP4 stsd offset overflow")?, + stsd.end, + )?; + ensure!( + entries.len() == 1, + "invalid MP4 video sample description extent" + ); + let entry = entries.first().context("MP4 video sample entry missing")?; + let codec = match &entry.kind { + b"av01" => "av1", + b"vp08" => "vp8", + b"vp09" => "vp9", + _ => return Ok(None), + }; + ensure!( + entry.end - entry.body >= 78, + "truncated MP4 visual sample entry" + ); + Ok(Some(codec)) +} + /// Convert rational timestamps to the nearest whole decoded sample frame. fn frames(units: u64, scale: u32, rate: u32) -> Result { ensure!(scale > 0 && rate > 0, "invalid MP4 timing scale"); @@ -334,6 +401,72 @@ pub(super) fn read( mod tests { use super::*; + #[test] + fn video_codec_declarations_obey_track_identity_and_box_boundaries() -> Result<()> { + let original = include_bytes!("../../tests/fixtures/media/av1.mp4"); + let directory = tempfile::tempdir()?; + let path = directory.path().join("misleading.webm"); + std::fs::write(&path, original)?; + ensure!( + declared_video_codec(&path, 1, || Ok(()))? == Some("av1"), + "AV1 declaration lost" + ); + ensure!( + declared_video_codec(&path, 999, || Ok(())).is_err(), + "wrong track matched" + ); + ensure!( + declared_video_codec(&path, 1, || anyhow::bail!("cancelled")).is_err(), + "cancelled inspection continued" + ); + + let stsd = original + .windows(4) + .position(|bytes| bytes == b"stsd") + .context("missing sample descriptions")?; + let entry = stsd + .checked_add(16) + .context("sample entry offset overflow")?; + ensure!( + original.get(entry..entry + 4) == Some(b"av01"), + "fixture layout changed" + ); + let mut unrelated_tag = original.to_vec(); + unrelated_tag + .get_mut(entry..entry + 4) + .context("sample entry bounds")? + .copy_from_slice(b"zzzz"); + // An AV1-looking tag inside an unrelated free-box payload is not metadata. + unrelated_tag.extend_from_slice(b"\0\0\0\x0cfreeav01"); + std::fs::write(&path, unrelated_tag)?; + ensure!( + declared_video_codec(&path, 1, || Ok(()))?.is_none(), + "payload tag became a codec" + ); + + let mut excessive_entries = original.to_vec(); + excessive_entries + .get_mut(stsd + 8..stsd + 12) + .context("stsd count bounds")? + .copy_from_slice(&u32::MAX.to_be_bytes()); + std::fs::write(&path, excessive_entries)?; + ensure!( + declared_video_codec(&path, 1, || Ok(())).is_err(), + "unbounded sample descriptions accepted" + ); + std::fs::write( + &path, + original + .get(..entry + 3) + .context("truncated fixture bounds")?, + )?; + ensure!( + declared_video_codec(&path, 1, || Ok(())).is_err(), + "truncated movie accepted" + ); + Ok(()) + } + /// Empty edits, gaps, and multiple forward ranges preserve presentation order. #[test] fn silence_and_forward_ranges_are_streamed_without_padding() -> Result<()> { diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs index 7db3b668..b8eaa16d 100644 --- a/src/media/ffmpeg.rs +++ b/src/media/ffmpeg.rs @@ -35,10 +35,99 @@ const VP9_COMPAT_PIXEL_FORMAT: &str = "yuv420p"; /// Color metadata used for broadly compatible VP9 output. const VP9_COMPAT_COLOR_SPACE: &str = "bt709"; -/// Cached output from probing the configured encoder list. -static ENCODER_LIST: LazyLock> = LazyLock::new(|| { - output_stdout_with_timeout(&crate::config::CONFIG.ffmpeg_path, &["-encoders"]) -}); +/// AV1 decoding and encoding are independent build capabilities. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Av1Capabilities { + /// Specific AV1 decoder names reported by `FFmpeg`. + pub decoders: Vec, + /// Specific AV1 encoder names reported by `FFmpeg`. + pub encoders: Vec, +} + +/// Capabilities of the retained video backend, interrogated independently. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct VideoCapabilities { + /// Installed AV1 decoders and encoders, not inferred from the version. + pub av1: Av1Capabilities, + /// The policy-selected VP9 encoder exists. + pub vp9: bool, + /// The policy-selected Opus encoder exists. + pub opus: bool, + /// The `WebM` output muxer exists. + pub webm_muxer: bool, +} + +impl VideoCapabilities { + /// Whether the established VP9/Opus `WebM` output pipeline is available. + #[must_use] + pub const fn webm_available(&self) -> bool { + self.vp9 && self.opus && self.webm_muxer + } +} + +/// Probe the configured build once; missing or failed lists fail closed. +#[must_use] +pub fn video_capabilities() -> &'static VideoCapabilities { + static CAPS: LazyLock = LazyLock::new(|| { + let program = &crate::config::CONFIG.ffmpeg_path; + capabilities_from_lists( + output_stdout_with_timeout(program, &["-hide_banner", "-decoders"]).as_deref(), + output_stdout_with_timeout(program, &["-hide_banner", "-encoders"]).as_deref(), + output_stdout_with_timeout(program, &["-hide_banner", "-muxers"]).as_deref(), + ) + }); + &CAPS +} + +/// Parse actual capability rows, excluding legends and description-only matches. +fn codec_rows(output: &str, kind: char) -> impl Iterator { + output.lines().filter_map(move |line| { + let mut fields = line.split_whitespace(); + let flags = fields.next()?; + let name = fields.next()?; + if flags.len() != 6 || !flags.starts_with(kind) || name == "=" { + return None; + } + Some((name, line)) + }) +} + +/// Recognize codec aliases by their declared codec, including future implementations. +fn av1_names(output: &str) -> Vec { + codec_rows(output, 'V') + .filter(|(name, line)| { + line.contains("(codec av1)") + || matches!( + *name, + "av1" | "libaom-av1" | "libsvtav1" | "librav1e" | "libdav1d" + ) + || name.starts_with("av1_") + || name.ends_with("_av1") + }) + .map(|(name, _)| name.to_owned()) + .collect() +} + +/// Combine independently probed build lists into video pipeline capabilities. +fn capabilities_from_lists( + decoders: Option<&str>, + encoders: Option<&str>, + muxers: Option<&str>, +) -> VideoCapabilities { + let encoders = encoders.unwrap_or(""); + VideoCapabilities { + av1: Av1Capabilities { + decoders: av1_names(decoders.unwrap_or("")), + encoders: av1_names(encoders), + }, + vp9: codec_rows(encoders, 'V').any(|(name, _)| name == "libvpx-vp9"), + opus: codec_rows(encoders, 'A').any(|(name, _)| name == "libopus"), + webm_muxer: muxers.unwrap_or("").lines().any(|line| { + let mut fields = line.split_whitespace(); + fields.next() == Some("E") && fields.next() == Some("webm") + }), + } +} /// Probe whether the `ffmpeg` binary is reachable on the current PATH. /// @@ -119,39 +208,45 @@ fn mapped_stream_exists(path: &str, selector: &str, frames: &str) -> Result Result<()> { - let in_str = path_to_str(input)?; - let out_str = path_to_str(output)?; + let parent = output.parent().context("thumbnail output has no parent")?; + let frame = tempfile::Builder::new() + .suffix(".png") + .tempfile_in(parent)?; + let thumbnail = tempfile::Builder::new() + .suffix(".webp") + .tempfile_in(parent)?; let scale = format!("scale='if(gt(iw,ih),{max_dim},-2)':'if(gt(iw,ih),-2,{max_dim})'"); - run_ffmpeg(&[ "-loglevel", "error", "-i", - in_str, - "-vframes", + path_to_str(input)?, + "-map", + "0:v:0", + "-frames:v", "1", "-vf", &scale, "-c:v", - "libwebp", - "-quality", - "80", + "png", + "-f", + "image2", + "-update", + "1", "-y", - out_str, + path_to_str(frame.path())?, ]) - .with_context(|| format!("thumbnail generation failed for {in_str}")) + .context("video frame extraction failed")?; + super::thumbnail::image_crate_thumbnail(frame.path(), "image/png", thumbnail.path(), max_dim)?; + thumbnail + .persist(output) + .context("persist Rust-encoded video thumbnail")?; + Ok(()) } #[must_use] @@ -175,6 +270,10 @@ pub fn build_vp9_transcode_args(input: &str, output: &str) -> Vec { "error", "-i", input, + "-map", + "0:v:0", + "-map", + "0:a:0?", "-c:v", "libvpx-vp9", "-deadline", @@ -215,6 +314,8 @@ pub fn build_vp9_transcode_args(input: &str, output: &str) -> Vec { "libopus", "-b:a", "128k", + "-f", + "webm", "-map_metadata", "-1", "-y", @@ -352,53 +453,97 @@ fn output_stdout_with_timeout(program: &str, args: &[&str]) -> Option { .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) } -/// Probe whether the current ffmpeg binary has the `libwebp` encoder compiled in. -/// -/// Runs `ffmpeg -encoders` and scans stdout for a line containing `libwebp`. -/// Returns `true` only when ffmpeg is present AND the encoder is available. -/// -/// This is a synchronous, blocking call intended for use at server startup -/// inside a `spawn_blocking` task, or called directly from the startup path -/// where blocking is acceptable. -#[must_use] -pub fn check_webp_encoder() -> bool { - ENCODER_LIST - .as_deref() - .is_some_and(|stdout| stdout.lines().any(|line| line.contains("libwebp"))) -} +#[cfg(test)] +mod tests { + use super::{ + build_vp9_transcode_args, capabilities_from_lists, VP9_COMPAT_COLOR_SPACE, + VP9_COMPAT_PIXEL_FORMAT, + }; + use anyhow::{ensure, Context as _, Result}; -/// Probe whether the current ffmpeg binary has the `libvpx-vp9` encoder compiled in. -/// -/// Required for MP4→WebM and WebM/AV1→WebM/VP9 transcoding. Runs -/// `ffmpeg -encoders` and scans stdout for a line containing `libvpx-vp9`. -/// Returns `true` only when ffmpeg is present AND the encoder is available. -/// -/// This is a synchronous, blocking call intended for use at server startup. -#[must_use] -pub fn check_vp9_encoder() -> bool { - ENCODER_LIST - .as_deref() - .is_some_and(|stdout| stdout.lines().any(|line| line.contains("libvpx-vp9"))) -} + #[test] + fn av1_decoder_does_not_imply_av1_encoder_or_webm_conversion() { + let caps = capabilities_from_lists( + Some(" V..... libdav1d dav1d AV1 decoder (codec av1)\n V....D av1 AV1"), + Some(" A....D wmav1 Windows Media Audio 1"), + Some(" E webm WebM"), + ); + assert_eq!(caps.av1.decoders, ["libdav1d", "av1"]); + assert_eq!(caps.av1.encoders, Vec::::new()); + assert!(!caps.webm_available()); + } -/// Probe whether the current ffmpeg binary has the `libopus` encoder compiled in. -/// -/// Required for audio encoding during MP4→WebM and WebM/AV1→WebM/VP9 transcoding. -/// Runs `ffmpeg -encoders` and scans stdout for a line containing `libopus`. -/// Returns `true` only when ffmpeg is present AND the encoder is available. -/// -/// This is a synchronous, blocking call intended for use at server startup. -#[must_use] -pub fn check_opus_encoder() -> bool { - ENCODER_LIST - .as_deref() - .is_some_and(|stdout| stdout.lines().any(|line| line.contains("libopus"))) -} + #[test] + fn av1_encoder_names_and_webm_requirements_are_independent() { + let encoders = " V....D libaom-av1 libaom AV1\n V..... libsvtav1 SVT (codec av1)\n V..... librav1e rav1e AV1\n V..... av1_nvenc NVIDIA AV1\n V..... future_encoder future (codec av1)\n V....D libvpx-vp9 VP9\n A....D libopus Opus"; + let caps = capabilities_from_lists(None, Some(encoders), Some(" E webm WebM")); + assert_eq!(caps.av1.decoders, Vec::::new()); + assert_eq!( + caps.av1.encoders, + [ + "libaom-av1", + "libsvtav1", + "librav1e", + "av1_nvenc", + "future_encoder" + ] + ); + assert!(caps.webm_available()); + assert!(!capabilities_from_lists(None, Some(encoders), None).webm_available()); + assert!(!capabilities_from_lists( + None, + Some(" V..... libvpx-vp9 VP9"), + Some(" E webm WebM") + ) + .webm_available()); + assert!( + !capabilities_from_lists(None, Some(" A..... libopus Opus"), Some(" E webm WebM")) + .webm_available() + ); + } -#[cfg(test)] -mod tests { - use super::{build_vp9_transcode_args, VP9_COMPAT_COLOR_SPACE, VP9_COMPAT_PIXEL_FORMAT}; - use anyhow::{Context as _, Result}; + #[test] + fn unavailable_ffmpeg_and_description_matches_fail_closed() { + let missing = capabilities_from_lists(None, None, None); + assert_eq!(missing, super::VideoCapabilities::default()); + let misleading = capabilities_from_lists( + Some(" V..... h264 text mentions av1\n A..... av1 not video"), + Some(" V..... unrelated mentions libvpx-vp9 libopus libaom-av1\n V..... libvpx-vp9-extra other\n A..... libopus_extra other\n V..... = libaom-av1"), + Some(" E webm_chunk WebM Chunk\n E other mentions webm"), + ); + assert_eq!(misleading, missing); + } + + #[test] + fn real_av1_inputs_convert_to_vp9_webm_and_native_webp_thumbnails() -> Result<()> { + if !super::detect_ffmpeg() { + return Ok(()); + } + let caps = super::video_capabilities(); + if !caps.webm_available() || caps.av1.decoders.is_empty() { + return Ok(()); + } + let dir = tempfile::tempdir()?; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + for name in ["av1.webm", "av1.mkv", "av1.mp4"] { + let input = root.join(name); + let output = dir.path().join(format!("{name}.webm")); + let args = build_vp9_transcode_args( + input.to_str().context("input path")?, + output.to_str().context("output path")?, + ); + super::run_ffmpeg(&args.iter().map(String::as_str).collect::>())?; + crate::media::probe::validate_webm_output_file(&output)?; + let thumbnail = dir.path().join(format!("{name}.webp")); + super::ffmpeg_thumbnail(&input, &thumbnail, 32)?; + let image = image::open(&thumbnail)?; + ensure!( + image.width() == 32 && image.height() == 32, + "bad video thumbnail" + ); + } + Ok(()) + } fn paired_arg_index(args: &[String], flag: &str, value: &str) -> Option { args.windows(2).position(|window| { @@ -496,8 +641,8 @@ mod tests { fn ffmpeg_builders_with_forced_compat_pixel_formats_normalise_color_metadata() -> Result<()> { let args = build_vp9_transcode_args("input.mp4", "output.webm"); - // Other ffmpeg builders in this crate either produce WebP thumbnails/images - // through libwebp or audio waveform PNGs and do not force VP9/H.26x-style + // Other ffmpeg builders extract PNG video frames or audio waveform + // PNGs and do not force VP9/H.26x-style // compatibility pixel formats. VP9 transcode is the path that combines // a forced yuv420p profile-0 output with potentially inherited source // color metadata, so it owns the explicit BT.709 normalization contract. diff --git a/src/media/heif.rs b/src/media/heif.rs index 87721675..f6a7449a 100644 --- a/src/media/heif.rs +++ b/src/media/heif.rs @@ -223,7 +223,6 @@ mod tests { &thumbnail, 64, false, - false, )?; let thumb = image::open(thumbnail)?; ensure!( diff --git a/src/media/images/tests.rs b/src/media/images/tests.rs index 054500b0..aa6fff4e 100644 --- a/src/media/images/tests.rs +++ b/src/media/images/tests.rs @@ -219,7 +219,6 @@ fn static_formats_encode_to_decodable_metadata_free_webp() -> Result<()> { &thumbnail, 6, false, - false, )?; ensure!( image::open(thumbnail)?.into_rgba8().dimensions() == (6, 3), @@ -269,7 +268,7 @@ fn jpeg_exif_rotation_survives_conversion_and_thumbnailing_without_tools() -> Re "JPEG EXIF rotation was lost" ); let thumbnail = dir.path().join("thumb.webp"); - super::super::thumbnail::generate_thumbnail(&input, "image/jpeg", &thumbnail, 6, false, false)?; + super::super::thumbnail::generate_thumbnail(&input, "image/jpeg", &thumbnail, 6, false)?; ensure!( image::open(thumbnail)?.into_rgba8().dimensions() == (3, 6), "oriented thumbnail dimensions changed" @@ -308,7 +307,7 @@ fn large_valid_image_and_truncated_formats_preserve_atomic_output() -> Result<() let pixels = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4096, 2048, Rgba([42, 73, 101, 128]))); pixels.save_with_format(&input, ImageFormat::Png)?; - super::super::thumbnail::generate_thumbnail(&input, "image/png", &output, 256, false, false)?; + super::super::thumbnail::generate_thumbnail(&input, "image/png", &output, 256, false)?; let image = decode_still(&output)?.into_rgba8(); ensure!( image.dimensions() == (256, 128), diff --git a/src/media/mod.rs b/src/media/mod.rs index 39c01ea2..9de3cdf9 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -127,9 +127,6 @@ pub struct ProcessedMedia { pub struct MediaProcessor { /// Whether the `ffmpeg` binary was detected on startup. pub ffmpeg_available: bool, - /// Whether the libwebp encoder is compiled into the detected ffmpeg build. - /// Controls the retained video-frame thumbnail encoder. - pub ffmpeg_webp_available: bool, } impl MediaProcessor { @@ -146,32 +143,13 @@ impl MediaProcessor { "ffmpeg not found — video transcoding and video thumbnails are disabled." ); } - let mut processor = Self::new_with_ffmpeg(available); - processor.ffmpeg_webp_available = available && ffmpeg::check_webp_encoder(); - processor + Self::new_with_ffmpeg(available) } - /// Create a `MediaProcessor` with pre-detected capability flags. - /// - /// Use this in request handlers to avoid re-detecting ffmpeg on every upload. - /// Both flags should come from `AppState` which is populated once at startup. - #[must_use] - pub const fn new_with_ffmpeg_caps(ffmpeg_available: bool, ffmpeg_webp_available: bool) -> Self { - Self { - ffmpeg_available, - ffmpeg_webp_available, - } - } - - /// Convenience constructor when only the base ffmpeg flag is known. - /// `ffmpeg_webp_available` defaults to the same value as `ffmpeg_available`. - /// Prefer [`new_with_ffmpeg_caps`](Self::new_with_ffmpeg_caps) in handlers. + /// Create a processor with the video capability detected at startup. #[must_use] pub const fn new_with_ffmpeg(ffmpeg_available: bool) -> Self { - Self { - ffmpeg_available, - ffmpeg_webp_available: ffmpeg_available, - } + Self { ffmpeg_available } } /// Process an uploaded file: convert to an optimal web format and generate @@ -244,7 +222,6 @@ impl MediaProcessor { file_stem, conv.final_mime, self.ffmpeg_available, - self.ffmpeg_webp_available, )) } None => self.generate_thumbnail( @@ -317,13 +294,8 @@ impl MediaProcessor { file_stem: &str, thumb_max: u32, ) -> Result { - let thumb_path = thumbnail::thumbnail_output_path( - thumb_dir, - file_stem, - mime, - self.ffmpeg_available, - self.ffmpeg_webp_available, - ); + let thumb_path = + thumbnail::thumbnail_output_path(thumb_dir, file_stem, mime, self.ffmpeg_available); // Forward the actual path returned by generate_thumbnail (may differ from // thumb_path when a video placeholder falls back to .svg extension). @@ -333,7 +305,6 @@ impl MediaProcessor { &thumb_path, thumb_max, self.ffmpeg_available, - self.ffmpeg_webp_available, ) } } diff --git a/src/media/probe.rs b/src/media/probe.rs index 045b711a..df3afc5d 100644 --- a/src/media/probe.rs +++ b/src/media/probe.rs @@ -65,6 +65,174 @@ pub struct MediaInfo { pub audio_codec: Option<&'static str>, } +/// Containers accepted by the `WebM` conversion policy, distinct from codecs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum VideoContainer { + /// EBML with the `WebM` document type. + Webm, + /// EBML with the Matroska document type. + Matroska, + /// ISO base media container (including MP4). + Mp4, + /// Another container recognized by the Rust reader. + Other, +} + +/// Video metadata used for conversion decisions and output validation. +#[derive(Debug)] +pub(crate) struct VideoInfo { + /// Container established from signatures and the actual format reader. + pub container: VideoContainer, + /// Video codecs in stream order; never inferred from filename or MIME. + pub codecs: Vec<&'static str>, + /// Audio codecs in stream order. + pub audio_codecs: Vec<&'static str>, + /// First video stream's coded dimensions. + pub dimensions: Option<(u16, u16)>, + /// Duration from the container timebase, when declared. + pub duration: Option, +} + +impl VideoInfo { + /// Preserve compatible VP8/VP9 `WebM`; AV1 follows the established VP9 policy. + pub(crate) fn needs_webm_conversion(&self) -> bool { + self.container != VideoContainer::Webm + || self.codecs.is_empty() + || !self + .codecs + .iter() + .all(|codec| matches!(*codec, "vp8" | "vp9")) + || !self + .audio_codecs + .iter() + .all(|codec| matches!(*codec, "opus" | "vorbis")) + } + + /// Enforce the generated VP9/Opus `WebM` contract before promotion. + pub(crate) fn validate_webm_output(&self) -> Result<()> { + ensure!( + self.container == VideoContainer::Webm, + "output container is not WebM" + ); + ensure!( + self.codecs.as_slice() == ["vp9"], + "output must contain one VP9 video stream" + ); + ensure!( + self.audio_codecs.is_empty() || self.audio_codecs.as_slice() == ["opus"], + "output audio must be Opus" + ); + let (width, height) = self.dimensions.context("output has no video dimensions")?; + ensure!(width > 0 && height > 0, "output has zero video dimensions"); + ensure!( + u64::from(width) * u64::from(height) <= super::MAX_UNTRUSTED_IMAGE_PIXELS, + "output video dimensions exceed the media pixel budget" + ); + if let Some(duration) = self.duration { + // Time::is_positive in Symphonia 0.6 checks whole seconds only. + ensure!( + duration > symphonia::core::units::Time::ZERO, + "output duration is not positive" + ); + } + Ok(()) + } +} + +/// Inspect video tracks with the existing bounded Rust container reader. +/// The header determines `WebM` versus Matroska; the parser determines codecs. +pub(crate) fn inspect_video(path: &Path) -> Result { + use std::io::Read as _; + use symphonia::core::formats::well_known::{FORMAT_ID_ISOMP4, FORMAT_ID_MKV}; + let format = open_format(path)?; + let mut header = Vec::with_capacity(512); + File::open(path)?.take(512).read_to_end(&mut header)?; + let container = match format.format_info().format { + FORMAT_ID_MKV => match crate::utils::files::video_container_mime(&header)? { + "video/webm" => VideoContainer::Webm, + "video/x-matroska" => VideoContainer::Matroska, + _ => anyhow::bail!("Matroska parser and header disagree"), + }, + FORMAT_ID_ISOMP4 => VideoContainer::Mp4, + _ => VideoContainer::Other, + }; + let mut codecs = Vec::new(); + let mut audio_codecs = Vec::new(); + let mut dimensions = None; + for track in format.tracks() { + match &track.codec_params { + Some(CodecParameters::Video(params)) => { + if codecs.is_empty() { + dimensions = params.width.zip(params.height); + } + codecs.push(video_track_codec( + path, + track.id, + params.codec, + container == VideoContainer::Mp4, + )?); + } + Some(CodecParameters::Audio(params)) => { + audio_codecs.push(audio_codec_name(params.codec)); + } + _ => {} + } + } + ensure!(!codecs.is_empty(), "container has no video stream"); + let media = format.media_info(); + let duration = media + .time_base + .zip(media.duration) + .map(|(base, duration)| { + base.calc_duration(duration) + .context("video duration overflow") + }) + .transpose()?; + Ok(VideoInfo { + container, + codecs, + audio_codecs, + dimensions, + duration, + }) +} + +/// Scan generated packets to EOF with bounded reads; reject header-only output. +fn validate_video_packets(path: &Path) -> Result<()> { + let (mut format, window) = open_bounded_format( + path, + Instant::now(), + Duration::from_secs(crate::config::ffmpeg_timeout_secs()), + None, + )?; + let track_id = format + .first_track(TrackType::Video) + .context("missing video stream")? + .id; + let mut has_video = false; + loop { + window.start_packet(); + let Some(packet) = format.next_packet()? else { + break; + }; + if packet.track_id == track_id { + ensure!(!packet.data.is_empty(), "empty video packet"); + has_video = true; + } + } + ensure!(has_video, "output has no video packets"); + Ok(()) +} + +/// Validate generated VP9/Opus `WebM` metadata and its bounded packet stream. +/// +/// # Errors +/// Returns an error for the wrong container, codec, geometry, duration or packets. +pub fn validate_webm_output_file(path: &Path) -> Result<()> { + inspect_video(path)?.validate_webm_output()?; + validate_video_packets(path) +} + /// Bound parser work even while it is scanning headers before returning packets. struct ContainerSource { /// The validated regular file. @@ -302,7 +470,15 @@ pub fn inspect(path: &Path) -> Result { for track in format.tracks() { match &track.codec_params { Some(CodecParameters::Video(params)) => { - video_codec.get_or_insert_with(|| video_codec_name(params.codec)); + if video_codec.is_none() { + video_codec = Some(video_track_codec( + path, + track.id, + params.codec, + format.format_info().format + == symphonia::core::formats::well_known::FORMAT_ID_ISOMP4, + )?); + } } Some(CodecParameters::Audio(params)) => { audio_codec.get_or_insert_with(|| audio_codec_name(params.codec)); @@ -366,8 +542,39 @@ pub fn probe_audio_codec(path: &Path) -> Result { .map(str::to_owned) } +/// Supplement only missing MP4 codec declarations using the bounded box reader. +fn video_track_codec( + path: &Path, + track_id: u32, + codec: video::VideoCodecId, + is_mp4: bool, +) -> Result<&'static str> { + let name = video_codec_name(codec); + if name != "unknown" || !is_mp4 { + return Ok(name); + } + let started = Instant::now(); + let timeout = Duration::from_secs(crate::config::ffmpeg_timeout_secs()); + super::audio::mp4::declared_video_codec(path, track_id, || { + ensure!( + started.elapsed() < timeout, + "MP4 video codec inspection timed out" + ); + Ok(()) + }) + .map(|codec| codec.unwrap_or("unknown")) +} + /// Normalize common video codec declarations used by the upload/transcode paths. const fn video_codec_name(codec: video::VideoCodecId) -> &'static str { + // Normalize opaque sample-entry FourCC IDs as well as well-known IDs. + // These fixed ASCII literals satisfy FourCc::new's documented invariant. + const MP4_AV1: video::VideoCodecId = + video::VideoCodecId::new(symphonia::core::common::FourCc::new(*b"av01")); + const MP4_VP8: video::VideoCodecId = + video::VideoCodecId::new(symphonia::core::common::FourCc::new(*b"vp08")); + const MP4_VP9: video::VideoCodecId = + video::VideoCodecId::new(symphonia::core::common::FourCc::new(*b"vp09")); use video::well_known::{ CODEC_ID_AV1, CODEC_ID_H264, CODEC_ID_HEVC, CODEC_ID_MJPEG, CODEC_ID_MPEG4, CODEC_ID_VP8, CODEC_ID_VP9, @@ -377,9 +584,9 @@ const fn video_codec_name(codec: video::VideoCodecId) -> &'static str { CODEC_ID_MPEG4 => "mpeg4", CODEC_ID_MJPEG => "mjpeg", CODEC_ID_HEVC => "hevc", - CODEC_ID_VP8 => "vp8", - CODEC_ID_VP9 => "vp9", - CODEC_ID_AV1 => "av1", + CODEC_ID_VP8 | MP4_VP8 => "vp8", + CODEC_ID_VP9 | MP4_VP9 => "vp9", + CODEC_ID_AV1 | MP4_AV1 => "av1", _ => "unknown", } } @@ -420,6 +627,155 @@ mod tests { use std::io::Read as _; use symphonia::core::io::ReadBytes as _; + #[test] + fn av1_vp8_vp9_detection_and_container_policy_use_stream_metadata() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let dir = tempfile::tempdir()?; + let disguised = dir.path().join("misleading.jpg"); + for (name, container, codec, convert) in [ + ("av1.webm", VideoContainer::Webm, "av1", true), + ("av1.mkv", VideoContainer::Matroska, "av1", true), + ("av1.mp4", VideoContainer::Mp4, "av1", true), + ("vp8.webm", VideoContainer::Webm, "vp8", false), + ("video.webm", VideoContainer::Webm, "vp9", false), + ("video.mkv", VideoContainer::Matroska, "h264", true), + ("video.mp4", VideoContainer::Mp4, "h264", true), + ] { + std::fs::copy(root.join(name), &disguised)?; + let info = inspect_video(&disguised).with_context(|| format!("inspect {name}"))?; + ensure!( + info.container == container, + "wrong container for {name}: {info:?}" + ); + ensure!(info.codecs == [codec], "wrong codec for {name}: {info:?}"); + ensure!( + info.needs_webm_conversion() == convert, + "wrong policy for {name}" + ); + ensure!( + inspect(&disguised)?.video_codec == Some(codec), + "legacy probe disagrees" + ); + } + Ok(()) + } + + #[test] + fn webm_policy_checks_all_video_and_audio_tracks() { + let mut info = VideoInfo { + container: VideoContainer::Webm, + codecs: vec!["vp9"], + audio_codecs: vec!["opus", "vorbis"], + dimensions: Some((64, 64)), + duration: None, + }; + assert!(!info.needs_webm_conversion()); + for codec in ["av1", "h264", "unknown"] { + info.codecs = vec!["vp9", codec]; + assert!(info.needs_webm_conversion()); + } + info.codecs = vec!["vp9"]; + info.audio_codecs.push("aac"); + assert!(info.needs_webm_conversion()); + info.audio_codecs.clear(); + info.container = VideoContainer::Matroska; + assert!(info.needs_webm_conversion()); + } + + #[test] + fn generated_webm_validation_rejects_wrong_container_codec_geometry_and_duration() -> Result<()> + { + let mut info = VideoInfo { + container: VideoContainer::Webm, + codecs: vec!["vp9"], + audio_codecs: vec!["opus"], + dimensions: Some((64, 64)), + duration: Some(symphonia::core::units::Time::from_millis(400)), + }; + info.validate_webm_output()?; + for container in [ + VideoContainer::Matroska, + VideoContainer::Mp4, + VideoContainer::Other, + ] { + info.container = container; + ensure!( + info.validate_webm_output().is_err(), + "wrong container accepted" + ); + } + info.container = VideoContainer::Webm; + for codecs in [vec![], vec!["vp8"], vec!["av1"], vec!["vp9", "vp9"]] { + info.codecs = codecs; + ensure!( + info.validate_webm_output().is_err(), + "wrong video streams accepted" + ); + } + info.codecs = vec!["vp9"]; + info.audio_codecs = vec!["aac"]; + ensure!(info.validate_webm_output().is_err(), "wrong audio accepted"); + info.audio_codecs.clear(); + for dimensions in [ + None, + Some((0, 64)), + Some((64, 0)), + Some((u16::MAX, u16::MAX)), + ] { + info.dimensions = dimensions; + ensure!( + info.validate_webm_output().is_err(), + "invalid geometry accepted" + ); + } + info.dimensions = Some((64, 64)); + for duration in [ + symphonia::core::units::Time::ZERO, + symphonia::core::units::Time::from_millis(-1), + ] { + info.duration = Some(duration); + ensure!( + info.validate_webm_output().is_err(), + "invalid duration accepted" + ); + } + info.duration = None; + info.validate_webm_output()?; + Ok(()) + } + + #[test] + fn generated_webm_file_validation_ignores_extension_and_rejects_invalid_files() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let dir = tempfile::tempdir()?; + let output = dir.path().join("generated.webm"); + for name in [ + "av1.webm", + "av1.mkv", + "av1.mp4", + "vp8.webm", + "video.mkv", + "video.mp4", + "audio.webm", + ] { + std::fs::copy(root.join(name), &output)?; + ensure!( + validate_webm_output_file(&output).is_err(), + "invalid output {name} accepted" + ); + } + for bytes in [b"".as_slice(), b"partial WebM", b"\x1a\x45\xdf\xa3\x01"] { + std::fs::write(&output, bytes)?; + ensure!( + validate_webm_output_file(&output).is_err(), + "partial output accepted" + ); + } + std::fs::copy(root.join("video.webm"), &output)?; + validate_webm_output_file(&output)?; + Ok(()) + } + #[test] fn parser_exact_reads_obey_packet_budget_cancellation_and_deadline() -> Result<()> { let file = tempfile::tempfile()?; diff --git a/src/media/thumbnail.rs b/src/media/thumbnail.rs index e3aa31b9..f1dee3ab 100644 --- a/src/media/thumbnail.rs +++ b/src/media/thumbnail.rs @@ -125,51 +125,20 @@ pub fn override_pdf_renderer_mode(mode: TestPdfRendererMode) -> PdfRendererTestG } } -/// Generate a thumbnail for a media file and write it to `output_path`. +/// Generate a thumbnail and return the actual path written. /// -/// All thumbnails are produced as WebP. The strategy depends on the MIME -/// type and whether ffmpeg (and its libwebp encoder) is available: -/// -/// | Source MIME | ffmpeg | libwebp | Action | -/// |-------------------|--------|---------|---------------------------------| -/// | `image/*` | either | — | Rust decode → resize → WebP | -/// | `application/pdf` | either | — | Rust preview or SVG placeholder | -/// | `video/webm` | yes | yes | ffmpeg first-frame + WebP | -/// | `video/webm` | yes | no | static SVG placeholder | -/// | `video/webm` | no | — | static SVG placeholder | -/// | `image/svg+xml` | either | — | static SVG placeholder | -/// | `audio/*` | either | — | static SVG placeholder | -/// -/// # Arguments -/// * `input_path` — Absolute path to the (already converted) media file. -/// * `mime` — Final MIME type of the input file. -/// * `output_path` — Where to write the thumbnail (WebP or SVG). -/// * `max_dim` — Maximum width and height in pixels (aspect preserved). -/// * `ffmpeg_available` — Whether ffmpeg was detected at startup. -/// * `ffmpeg_webp_available` — Whether ffmpeg has the libwebp encoder compiled in. +/// Images, including animated WebP, use the Rust decoder and WebP encoder. +/// Videos use `FFmpeg` only to extract a PNG frame, then the same Rust encoder. +/// Failed video extraction writes a `.svg` sibling placeholder. /// /// # Errors -/// Returns an error only if all strategies (including placeholder writing) -/// fail. Individual strategy failures are demoted to warnings so that a -/// thumbnail failure never causes the upload to fail. -/// Generate a thumbnail and return the **actual path written**. -/// -/// The returned path may differ from `output_path` when a fallback SVG -/// placeholder is written for a video whose thumbnail extraction failed. -/// `thumbnail_output_path` selects `.webp` for video when ffmpeg+libwebp are -/// both present (because it cannot know ahead of time whether ffmpeg will -/// succeed). If extraction fails, writing SVG bytes into a `.webp` file -/// produces a file whose content and extension disagree — browsers reject it -/// and show a broken thumbnail. To avoid this, the video fallback writes the -/// placeholder to a `.svg` sibling path instead and returns that path, so the -/// caller can store the correct path in the database. +/// Returns an error if thumbnail generation and placeholder writing both fail. pub fn generate_thumbnail( input_path: &Path, mime: &str, output_path: &Path, max_dim: u32, ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> Result { match mime { // SVG and audio: always use static placeholder @@ -193,19 +162,13 @@ pub fn generate_thumbnail( } } - // Video (WebM, MP4, and any other video/*): requires ffmpeg AND libwebp - // `thumbnail_output_path` pre-selects `.webp` when both are present. - // If ffmpeg_thumbnail then fails, write the SVG placeholder to the - // `.svg`-extension sibling so the file content and extension match. - // The `else` branch (ffmpeg absent / libwebp absent) already has the - // `.svg` extension pre-selected by `thumbnail_output_path`, so no - // rename is needed there. m if m.starts_with("video/") => { - if ffmpeg_available && ffmpeg_webp_available { + if ffmpeg_available { match ffmpeg::ffmpeg_thumbnail(input_path, output_path, max_dim) { Ok(()) => Ok(output_path.to_path_buf()), Err(e) => { tracing::warn!("ffmpeg video thumbnail failed ({}); using placeholder", e); + drop(std::fs::remove_file(output_path)); // Write the SVG placeholder with a .svg extension so its // content and file extension agree. Browsers that receive // SVG bytes served as image/webp silently show nothing. @@ -220,9 +183,7 @@ pub fn generate_thumbnail( } } - // WebP: skip ffmpeg entirely — use image crate directly - // ffmpeg fails on animated WebP (VP8L) and emits a spurious warning - // even though the image crate handles all WebP variants correctly. + // WebP previews always use the Rust decoder, including animation. "image/webp" => image_crate_thumbnail(input_path, mime, output_path, max_dim) .map(|()| output_path.to_path_buf()), @@ -236,26 +197,15 @@ pub fn generate_thumbnail( } } -/// Determine the correct output path for a thumbnail given the media MIME type. -/// -/// Always returns a `.webp` path, except for types that produce an -/// SVG placeholder (video without ffmpeg or libwebp, audio, svg source). -/// -/// # Arguments -/// * `thumb_dir` — The `thumbs/` directory (absolute path). -/// * `file_stem` — UUID stem shared with the media file. -/// * `mime` — Final MIME of the converted media file. -/// * `ffmpeg_available` — Whether ffmpeg was detected. -/// * `ffmpeg_webp_available` — Whether ffmpeg has the libwebp encoder. +/// Select a WebP thumbnail path, or SVG for audio, SVG and video without `FFmpeg`. #[must_use] pub fn thumbnail_output_path( thumb_dir: &Path, file_stem: &str, mime: &str, ffmpeg_available: bool, - ffmpeg_webp_available: bool, ) -> PathBuf { - let ext = thumbnail_extension(mime, ffmpeg_available, ffmpeg_webp_available); + let ext = thumbnail_extension(mime, ffmpeg_available); thumb_dir.join(format!("{file_stem}.{ext}")) } @@ -282,7 +232,7 @@ pub fn write_placeholder(output_path: &Path, kind: PlaceholderKind) -> Result<() /// /// Decodes `input_path`, resizes to fit within `max_dim × max_dim` (aspect /// preserved), and saves as WebP. This path handles all supported images. -fn image_crate_thumbnail( +pub(super) fn image_crate_thumbnail( input_path: &Path, _mime: &str, output_path: &Path, @@ -381,35 +331,12 @@ pub fn detect_pdf_renderers() -> Vec { vec![PdfRenderer::Hayro] } -/// Map a MIME type to an `image::ImageFormat` for decoding. -/// -/// Returns `None` for types the `image` crate cannot decode (video, SVG, -/// Return the file extension to use for a thumbnail. -/// -/// All thumbnails are `.webp` unless the source requires a static SVG -/// placeholder (video without ffmpeg, audio, SVG sources). -/// -/// For `video/webm`, a WebP thumbnail can only be produced when ffmpeg is -/// available AND the `libwebp` encoder is compiled in. When either is -/// absent, `ffmpeg_thumbnail` will fail and `write_placeholder` will be -/// called — we must pre-select `.svg` so the placeholder is written to a -/// path whose extension matches its actual SVG content. Mismatching the -/// extension (SVG bytes in a `.webp` file) causes browsers to reject the -/// file and display nothing. -fn thumbnail_extension( - mime: &str, - ffmpeg_available: bool, - ffmpeg_webp_available: bool, -) -> &'static str { +/// Select the extension for a thumbnail or its placeholder. +fn thumbnail_extension(mime: &str, ffmpeg_available: bool) -> &'static str { match mime { "image/svg+xml" => "svg", m if m.starts_with("audio/") => "svg", - // Video thumbnails need both ffmpeg (to demux the stream) AND libwebp - // (to encode the extracted frame as WebP). If either is missing the - // fallback is an SVG placeholder. This applies to all video/* types, - // not just WebM — MP4 and any other video format go through ffmpeg the - // same way and need the same extension pre-selection logic. - m if m.starts_with("video/") && (!ffmpeg_available || !ffmpeg_webp_available) => "svg", + m if m.starts_with("video/") && !ffmpeg_available => "svg", _ => "webp", } } @@ -425,7 +352,7 @@ mod tests { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media/inline-image.pdf"); let before = std::fs::read(&source)?; let output = dir.path().join("page.webp"); - let result = generate_thumbnail(&source, "application/pdf", &output, 100, false, false)?; + let result = generate_thumbnail(&source, "application/pdf", &output, 100, false)?; anyhow::ensure!( result.extension().and_then(std::ffi::OsStr::to_str) == Some("svg"), "unsupported preview did not use SVG" @@ -444,41 +371,34 @@ mod tests { #[test] fn thumbnail_ext_is_webp_for_images_no_ffmpeg() { - // Images use image-crate fallback, so webp even without ffmpeg - assert_eq!(thumbnail_extension("image/jpeg", false, false), "webp"); - assert_eq!(thumbnail_extension("image/png", false, false), "webp"); - assert_eq!(thumbnail_extension("image/webp", false, false), "webp"); + // Image previews use the built-in Rust encoder. + assert_eq!(thumbnail_extension("image/jpeg", false), "webp"); + assert_eq!(thumbnail_extension("image/png", false), "webp"); + assert_eq!(thumbnail_extension("image/webp", false), "webp"); } #[test] fn thumbnail_ext_is_svg_for_video_without_ffmpeg() { - assert_eq!(thumbnail_extension("video/webm", false, false), "svg"); - assert_eq!(thumbnail_extension("video/mp4", false, false), "svg"); - } - - #[test] - fn thumbnail_ext_is_svg_for_video_with_ffmpeg_but_no_webp() { - // ffmpeg available but libwebp missing — placeholder path must be .svg - assert_eq!(thumbnail_extension("video/webm", true, false), "svg"); - assert_eq!(thumbnail_extension("video/mp4", true, false), "svg"); + assert_eq!(thumbnail_extension("video/webm", false), "svg"); + assert_eq!(thumbnail_extension("video/mp4", false), "svg"); } #[test] - fn thumbnail_ext_is_webp_for_video_with_ffmpeg_and_webp() { - assert_eq!(thumbnail_extension("video/webm", true, true), "webp"); - assert_eq!(thumbnail_extension("video/mp4", true, true), "webp"); + fn thumbnail_ext_is_webp_for_video_with_ffmpeg() { + assert_eq!(thumbnail_extension("video/webm", true), "webp"); + assert_eq!(thumbnail_extension("video/mp4", true), "webp"); } #[test] fn thumbnail_ext_is_svg_for_audio() { - assert_eq!(thumbnail_extension("audio/mpeg", true, true), "svg"); - assert_eq!(thumbnail_extension("audio/mpeg", false, false), "svg"); + assert_eq!(thumbnail_extension("audio/mpeg", true), "svg"); + assert_eq!(thumbnail_extension("audio/mpeg", false), "svg"); } #[test] fn thumbnail_ext_is_svg_for_svg_source() { - assert_eq!(thumbnail_extension("image/svg+xml", true, true), "svg"); - assert_eq!(thumbnail_extension("image/svg+xml", false, false), "svg"); + assert_eq!(thumbnail_extension("image/svg+xml", true), "svg"); + assert_eq!(thumbnail_extension("image/svg+xml", false), "svg"); } #[test] @@ -519,7 +439,7 @@ mod tests { let output = tempdir.path().join("thumb.webp"); std::fs::write(&input, b"not decoded by image crate")?; - let actual = generate_thumbnail(&input, "image/heic", &output, 64, false, false)?; + let actual = generate_thumbnail(&input, "image/heic", &output, 64, false)?; assert_eq!( actual.extension().and_then(|ext| ext.to_str()), @@ -537,7 +457,7 @@ mod tests { let output = dir.path().join("thread.webp"); let _override = override_pdf_renderer_mode(TestPdfRendererMode::Fail); let generated = std::thread::spawn(move || { - generate_thumbnail(&input, "application/pdf", &output, 100, false, false) + generate_thumbnail(&input, "application/pdf", &output, 100, false) }) .join() .map_err(|_| anyhow::anyhow!("PDF preview test thread panicked"))??; diff --git a/src/middleware/state.rs b/src/middleware/state.rs index a69f0464..c809c1bd 100644 --- a/src/middleware/state.rs +++ b/src/middleware/state.rs @@ -399,8 +399,8 @@ pub struct AppState { pub db: crate::db::DbPool, /// Whether the `FFmpeg` executable is available. pub ffmpeg_available: bool, - /// Whether `FFmpeg` supports `WebP` output. - pub ffmpeg_webp_available: bool, + /// Independent AV1 decoder and encoder capabilities. + pub ffmpeg_av1: crate::media::ffmpeg::Av1Capabilities, /// Whether `FFmpeg` supports `VP9` output. pub ffmpeg_vp9_available: bool, /// Whether a usable `VP9` encoder is available. diff --git a/src/server/console/operator_views.rs b/src/server/console/operator_views.rs index c62cd18e..3db8315d 100644 --- a/src/server/console/operator_views.rs +++ b/src/server/console/operator_views.rs @@ -318,14 +318,14 @@ pub(super) fn render_system( fmt_bytes(stats.mem_bytes) } ), + "WebP/images: built in (Rust), including animation".to_owned(), format!( - "FFmpeg: {}", + "FFmpeg video: {}", snapshot.ffmpeg.map_or_else( || "Unknown · awaiting snapshot".to_owned(), - |(available, webp, vp9)| format!( - "{} · WebP {} · WebM VP9/Opus {}", + |(available, vp9)| format!( + "{} · WebM VP9/Opus {}", availability(available), - availability(webp), availability(vp9) ) ) diff --git a/src/server/console/telemetry.rs b/src/server/console/telemetry.rs index e81f91ac..ef6b76df 100644 --- a/src/server/console/telemetry.rs +++ b/src/server/console/telemetry.rs @@ -147,7 +147,7 @@ pub struct OperatorSnapshot { /// Queue capacity rejections since process startup. pub dropped: u64, /// `FFmpeg` capabilities detected at startup; None before first sample. - pub ffmpeg: Option<(bool, bool, bool)>, + pub ffmpeg: Option<(bool, bool)>, /// Maintenance currently holding the gate. pub maintenance: Option, /// Effective runtime automatic-backup interval and retention count. @@ -253,11 +253,7 @@ impl OperatorSnapshot { pub fn attach_runtime(&mut self, app: &AppState) { self.reconcile = crate::media::reconcile::metrics_snapshot(); self.dropped = app.job_queue.dropped_count(); - self.ffmpeg = Some(( - app.ffmpeg_available, - app.ffmpeg_webp_available, - app.ffmpeg_vp9_available, - )); + self.ffmpeg = Some((app.ffmpeg_available, app.ffmpeg_vp9_available)); self.maintenance = app .maintenance_gate .active_label() diff --git a/src/server/server.rs b/src/server/server.rs index eb3aa0bf..7ba9194f 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -495,8 +495,6 @@ async fn run_server_lifecycle( CONFIG.ffmpeg_path ); } - // libwebp is required only by the retained video-frame thumbnail command. - let ffmpeg_webp_available = crate::detect::detect_webp_encoder(ffmpeg_available); // libvpx-vp9 + libopus encoders: needed for MP4→WebM transcoding and // WebM/AV1→VP9 re-encoding. Checked independently so that a build missing // only these codecs still enables image conversion and thumbnail generation. @@ -582,7 +580,11 @@ async fn run_server_lifecycle( runtime_ready: Arc::new(std::sync::atomic::AtomicBool::new(false)), db: pool.clone(), ffmpeg_available, - ffmpeg_webp_available, + ffmpeg_av1: if ffmpeg_available { + crate::media::ffmpeg::video_capabilities().av1.clone() + } else { + crate::media::ffmpeg::Av1Capabilities::default() + }, ffmpeg_vp9_available, ffmpeg_vp9_encoder_available: ffmpeg_webm_status.vp9, ffmpeg_opus_available: ffmpeg_webm_status.opus, diff --git a/src/templates/admin.rs b/src/templates/admin.rs index 447bf22f..5870f2b5 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -364,8 +364,10 @@ pub struct AdminPanelSiteHealthView<'a> { pub struct AdminSiteHealthDependencySummary { /// `ffmpeg` detection state. pub ffmpeg: AdminDetectionStatus, - /// WebP encoder detection state. - pub webp: AdminDetectionStatus, + /// AV1 decoder availability; independent of encoding. + pub av1_decoder: AdminDetectionStatus, + /// AV1 encoder availability; output policy still uses VP9. + pub av1_encoder: AdminDetectionStatus, /// VP9 pipeline detection state. pub vp9: AdminDetectionStatus, /// Opus encoder detection state. @@ -426,8 +428,10 @@ impl AdminDetectionStatus { pub struct AdminMediaDetectionView { /// `ffmpeg` detection state. pub ffmpeg: AdminDetectionStatus, - /// WebP encoder detection state. - pub webp_encoder: AdminDetectionStatus, + /// AV1 decoder availability; independent of encoding. + pub av1_decoder: AdminDetectionStatus, + /// AV1 encoder availability; output policy still uses VP9. + pub av1_encoder: AdminDetectionStatus, /// VP9 and Opus pipeline detection state. pub vp9_pipeline: AdminDetectionStatus, /// Selected PDF thumbnail executable, when available. @@ -2262,7 +2266,9 @@ mod tests { tor_detail: "Set enable_tor_support = true in settings.toml, then restart RustChan.", dependency_summary: AdminSiteHealthDependencySummary { ffmpeg: AdminDetectionStatus::Detected, - webp: AdminDetectionStatus::Detected, + av1_decoder: AdminDetectionStatus::Missing, + av1_encoder: AdminDetectionStatus::Missing, + vp9: AdminDetectionStatus::Detected, opus: AdminDetectionStatus::Detected, }, @@ -2298,7 +2304,8 @@ mod tests { tor_detail: "Set enable_tor_support = true in settings.toml, then restart RustChan.", tor_state: AdminDashboardState::Disabled, dependency_status: "ready", - dependency_detail: "ffmpeg found; WebP found; VP9 found; Opus found.", + dependency_detail: + "WebP/images built in (Rust); ffmpeg video found; VP9 found; Opus found.", dependency_state: AdminDashboardState::Ok, job_status: "idle", job_detail: "Recently completed 0; backup job idle; restore jobs not available.", @@ -2392,7 +2399,9 @@ mod tests { media_max_active_content_size_bytes: 0, media_detection: AdminMediaDetectionView { ffmpeg: AdminDetectionStatus::Detected, - webp_encoder: AdminDetectionStatus::Detected, + av1_decoder: AdminDetectionStatus::Missing, + av1_encoder: AdminDetectionStatus::Missing, + vp9_pipeline: AdminDetectionStatus::Detected, pdf_thumbnail_renderer: Some("hayro (Rust)".to_owned()), }, @@ -3138,7 +3147,9 @@ mod tests { media_max_active_content_size_bytes: 0, media_detection: AdminMediaDetectionView { ffmpeg: AdminDetectionStatus::Missing, - webp_encoder: AdminDetectionStatus::Missing, + av1_decoder: AdminDetectionStatus::Missing, + av1_encoder: AdminDetectionStatus::Missing, + vp9_pipeline: AdminDetectionStatus::Missing, pdf_thumbnail_renderer: None, }, @@ -3149,6 +3160,8 @@ mod tests { }); assert!(html.contains("using built-in generic PDF placeholder thumbnail")); + assert!(html.contains("built in (Rust), including animated WebP")); + assert!(!html.contains("WebP encoder")); assert!(html.contains(r#"admin-detection-pill admin-detection-pill-missing">missing"#)); } @@ -3234,7 +3247,9 @@ mod tests { media_max_active_content_size_bytes: 0, media_detection: AdminMediaDetectionView { ffmpeg: AdminDetectionStatus::Detected, - webp_encoder: AdminDetectionStatus::Detected, + av1_decoder: AdminDetectionStatus::Missing, + av1_encoder: AdminDetectionStatus::Missing, + vp9_pipeline: AdminDetectionStatus::Detected, pdf_thumbnail_renderer: None, }, @@ -3318,7 +3333,9 @@ mod tests { media_max_active_content_size_bytes: 0, media_detection: AdminMediaDetectionView { ffmpeg: AdminDetectionStatus::Detected, - webp_encoder: AdminDetectionStatus::Detected, + av1_decoder: AdminDetectionStatus::Missing, + av1_encoder: AdminDetectionStatus::Missing, + vp9_pipeline: AdminDetectionStatus::Detected, pdf_thumbnail_renderer: None, }, diff --git a/src/templates/admin/maintenance.rs b/src/templates/admin/maintenance.rs index 1b106544..9609964c 100644 --- a/src/templates/admin/maintenance.rs +++ b/src/templates/admin/maintenance.rs @@ -173,14 +173,24 @@ fn render_media_detection_cards(view: &AdminPanelViewModel<'_>) -> String { "video thumbnails and transcoding entrypoint", ), ( - "WebP encoder", - view.maintenance.media_detection.webp_encoder.is_detected(), - "video frame thumbnails", + "WebP/images", + true, + "built in (Rust), including animated WebP", ), ( "VP9/WebM pipeline", view.maintenance.media_detection.vp9_pipeline.is_detected(), - "MP4 to WebM transcoding with VP9 + Opus", + "video conversion to WebM with VP9 + Opus", + ), + ( + "AV1 decoding", + view.maintenance.media_detection.av1_decoder.is_detected(), + "AV1 video inputs; independent of AV1 encoding", + ), + ( + "AV1 encoding", + view.maintenance.media_detection.av1_encoder.is_detected(), + "installed build capability; RustChan output uses VP9 + Opus", ), ( "PDF thumbnails", diff --git a/src/templates/admin/site_health.rs b/src/templates/admin/site_health.rs index afc191d3..68873223 100644 --- a/src/templates/admin/site_health.rs +++ b/src/templates/admin/site_health.rs @@ -211,9 +211,17 @@ fn render_dependency_summary(view: &AdminPanelViewModel<'_>) -> String { let mut rows = String::new(); for (label, status) in [ ("ffmpeg", dependencies.ffmpeg), - ("WebP support", dependencies.webp), + ( + "WebP/images (built in, Rust)", + AdminDetectionStatus::Detected, + ), ("VP9 support", dependencies.vp9), ("Opus support", dependencies.opus), + ("AV1 decoding", dependencies.av1_decoder), + ( + "AV1 encoding (output policy: VP9)", + dependencies.av1_encoder, + ), ] { append_health_row(&mut rows, label, detection_label(status)); } diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs index cd0f2bd4..1dba6078 100644 --- a/src/test_fixtures.rs +++ b/src/test_fixtures.rs @@ -80,7 +80,7 @@ pub(crate) fn app_state() -> crate::middleware::AppState { runtime_ready: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), db: pool, ffmpeg_available: false, - ffmpeg_webp_available: false, + ffmpeg_av1: crate::media::ffmpeg::Av1Capabilities::default(), ffmpeg_vp9_available: false, ffmpeg_vp9_encoder_available: false, ffmpeg_opus_available: false, diff --git a/src/utils/files.rs b/src/utils/files.rs index 875e19d6..a7c52beb 100644 --- a/src/utils/files.rs +++ b/src/utils/files.rs @@ -7,6 +7,10 @@ mod mime; /// Validated upload persistence. pub(crate) mod storage; +/// Inspect the content signature used to distinguish accepted video containers. +pub(crate) fn video_container_mime(header: &[u8]) -> anyhow::Result<&'static str> { + mime::detect_mime_type(header) +} pub use mime::fallback_download_mime_type; pub use storage::{ classify_upload_mime, delete_file_checked, format_file_size, mime_to_ext_pub, diff --git a/src/utils/files/storage.rs b/src/utils/files/storage.rs index a5bf079a..bb9cdd6a 100644 --- a/src/utils/files/storage.rs +++ b/src/utils/files/storage.rs @@ -49,8 +49,6 @@ pub struct SaveUploadOptions<'a> { pub max_pdf_size: usize, /// Whether the retained `FFmpeg` video backend is available. pub ffmpeg_available: bool, - /// Whether the installed `ffmpeg` supports WebP output. - pub ffmpeg_webp_available: bool, /// Whether otherwise-unrecognized files may be stored as downloads. pub allow_any_files: bool, } @@ -312,6 +310,9 @@ fn build_upload_plan( original_size: usize, options: &SaveUploadOptions<'_>, ) -> Result { + let video_needs_conversion = validated.media_type != crate::models::MediaType::Video + || !options.ffmpeg_available + || crate::media::probe::inspect_video(&validated.input_path)?.needs_webm_conversion(); let dest_dir = PathBuf::from(options.boards_dir).join(options.board_short); let thumbs_dir = dest_dir.join("thumbs"); std::fs::create_dir_all(&dest_dir).context("Failed to create board directory")?; @@ -323,8 +324,8 @@ fn build_upload_plan( .context("Upload thumbnail directory failed safety validation")?; } check_disk_space(&dest_dir, original_size)?; - let processing_pending = (validated.media_type == crate::models::MediaType::Audio - || options.ffmpeg_available) + let processing_pending = video_needs_conversion + && (validated.media_type == crate::models::MediaType::Audio || options.ffmpeg_available) && matches!( validated.media_type, crate::models::MediaType::Video | crate::models::MediaType::Audio @@ -511,10 +512,7 @@ fn save_processed_upload( file_id: &str, ) -> Result { let processor_input = prepare_processor_input(input_path, &plan.dest_dir, &plan.mime_type)?; - let processor = crate::media::MediaProcessor::new_with_ffmpeg_caps( - options.ffmpeg_available, - options.ffmpeg_webp_available, - ); + let processor = crate::media::MediaProcessor::new_with_ffmpeg(options.ffmpeg_available); let processed = processor .process_upload( processor_input.path(), @@ -1240,11 +1238,41 @@ mod tests { max_audio_size: 1024 * 1024, max_pdf_size: 1024 * 1024, ffmpeg_available: false, - ffmpeg_webp_available: false, allow_any_files: false, }) } + #[test] + fn video_upload_plans_use_container_codec_policy_without_extension_assumptions() -> Result<()> { + let dir = tempfile::tempdir()?; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + let input = dir.path().join("misleading.jpg"); + for (name, mime, pending) in [ + ("av1.webm", "video/webm", true), + ("av1.mkv", "video/x-matroska", true), + ("av1.mp4", "video/mp4", true), + ("vp8.webm", "video/webm", false), + ("video.webm", "video/webm", false), + ("video.mp4", "video/mp4", true), + ] { + let bytes = std::fs::read(root.join(name))?; + std::fs::write(&input, &bytes)?; + for ffmpeg in [true, false] { + let mut options = test_upload_options(dir.path(), "misleading.jpg")?; + options.ffmpeg_available = ffmpeg; + let validated = + super::validate_upload_for_storage(&input, &bytes, bytes.len(), &options)?; + let plan = super::build_upload_plan(validated, bytes.len(), &options)?; + anyhow::ensure!(plan.mime_type == mime, "wrong MIME for {name}"); + anyhow::ensure!( + plan.processing_pending == (pending && ffmpeg), + "wrong processing decision for {name}" + ); + } + } + Ok(()) + } + /// Creates upload options that permit arbitrary downloadable files. fn arbitrary_upload_options<'a>( root: &'a Path, diff --git a/src/workers/mod.rs b/src/workers/mod.rs index 417ab139..4e258ba2 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -1255,12 +1255,12 @@ fn media_job_identity(job: &Job) -> Option { } // VideoTranscode -/// Transcode an MP4 upload to `WebM` (VP9 + Opus), then update the post's -/// `file_path` and `mime_type`. The original MP4 is deleted on success. +/// Convert video to VP9/Opus `WebM`, then atomically update media metadata. +/// Compatible VP8/VP9 `WebM` is retained; source cleanup is journaled on success. /// /// Requires both `ffmpeg_available` (binary present) and `ffmpeg_vp9_available` -/// (libvpx-vp9 + libopus compiled in). If either flag is false the job is -/// skipped gracefully — no error is returned and the file remains as-is. +/// (libvpx-vp9 + libopus and the `WebM` muxer compiled in). If either is false, +/// the job is skipped gracefully — no error is returned and the file remains as-is. /// /// The command runs in its own process group. Timeout, cancellation, and scope /// cleanup terminate that whole group so descendants cannot outlive the job. @@ -1287,15 +1287,13 @@ async fn transcode_video( if !ffmpeg_vp9_available { warn!( - "VideoTranscode skipped for post {post_id}: libvpx-vp9 or libopus not available. \ + "VideoTranscode skipped for post {post_id}: libvpx-vp9, libopus or the WebM muxer not available. \ Install ffmpeg with VP9 + Opus support to enable MP4/MKV→WebM transcoding." ); return Ok(JobExecution::NeedsCompletion); } let timeout_secs = crate::config::ffmpeg_timeout_secs(); - let ffmpeg_timeout = Duration::from_secs(timeout_secs); - // File checks, codec probing, and temporary-file creation are blocking. let prepare_result = { let file_path2 = file_path.clone(); @@ -1311,10 +1309,29 @@ async fn transcode_video( return Ok(JobExecution::NeedsCompletion); }; - // `kill_on_drop` terminates ffmpeg when its timeout future is dropped. let mut command = ffmpeg_command(); + command.args(&args); + run_video_transcode(command, post_id, timeout_secs, cancel).await?; + + // File persistence and database updates are blocking. + let finalise_result = tokio::task::spawn_blocking(move || { + transcode_video_finalise( + job_id, post_id, &file_path, &src_path, &webm_abs, &webm_rel, tmp, &pool, + ) + }) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking panicked in finalise: {e}"))?; + finalise_result +} + +/// Run the bounded video process; only a successful exit permits finalisation. +async fn run_video_transcode( + mut command: TokioCommand, + post_id: i64, + timeout_secs: u64, + cancel: CancellationToken, +) -> Result<()> { command - .args(&args) .stderr(Stdio::piped()) .stdout(Stdio::null()) .kill_on_drop(true); @@ -1323,6 +1340,7 @@ async fn transcode_video( .map_err(|e| anyhow::anyhow!("failed to spawn ffmpeg '{}': {e}", CONFIG.ffmpeg_path))?; drop(command); + let ffmpeg_timeout = Duration::from_secs(timeout_secs); match wait_for_ffmpeg_output(child, ffmpeg_timeout, cancel).await? { AsyncWaitOutcome::Exited(output) => { if !output.status.success() { @@ -1346,23 +1364,14 @@ async fn transcode_video( } } - // File persistence and database updates are blocking. - let finalise_result = tokio::task::spawn_blocking(move || { - transcode_video_finalise( - job_id, post_id, &file_path, &src_path, &webm_abs, &webm_rel, tmp, &pool, - ) - }) - .await - .map_err(|e| anyhow::anyhow!("spawn_blocking panicked in finalise: {e}"))?; - finalise_result + Ok(()) } -/// Prepare a video transcode: validate the source file, optionally probe the -/// codec for `WebM` inputs, create a temp output file, and return the ffmpeg +/// Prepare a video transcode: validate the source file, inspect container and +/// stream metadata, create a temp output file, and return the ffmpeg /// argument list along with the relevant paths. /// -/// Returns `Ok(None)` when the job should be skipped gracefully (unrecognised -/// extension, or `WebM` that is already VP8/VP9). Returns `Ok(Some(...))` when +/// Returns `Ok(None)` for compatible VP8/VP9 `WebM`. Returns `Ok(Some(...))` when /// ffmpeg should be invoked. `Err` for genuine failures. /// /// This is a pure synchronous function; call it from `spawn_blocking` or at @@ -1377,10 +1386,6 @@ type TranscodePrepareParts = ( tempfile::NamedTempFile, ); -#[expect( - clippy::cognitive_complexity, - reason = "source validation and codec-specific preparation share one preflight boundary" -)] /// Validates and prepares all paths and arguments needed for a video transcode. fn transcode_video_prepare( post_id: i64, @@ -1396,34 +1401,19 @@ fn transcode_video_prepare( .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); - if ext != "mp4" && ext != "webm" && ext != "mkv" { - debug!("VideoTranscode: skipping unrecognised extension {file_path}"); + let info = crate::media::probe::inspect_video(&src) + .context("VideoTranscode: failed to inspect source container/streams")?; + if !info.needs_webm_conversion() { + debug!("VideoTranscode: preserving compatible VP8/VP9 WebM for post {post_id}"); return Ok(None); } - - if ext == "webm" { - let src_str = src - .to_str() - .ok_or_else(|| anyhow::anyhow!("Source path is non-UTF-8: {}", src.display()))?; - match crate::media::probe::probe_video_codec(src_str) { - Ok(codec) if codec == "av1" => { - tracing::info!(target: "workers", post_id = post_id, codec = "av1", "VideoTranscode: re-encoding WebM/AV1 to VP9"); - } - Ok(codec) => { - debug!( - "VideoTranscode: skipping WebM with codec '{}' for post {} (already VP8/VP9)", - codec, post_id - ); - return Ok(None); - } - Err(e) => { - warn!( - "VideoTranscode: could not probe codec for post {} ({}); skipping", - post_id, e - ); - return Ok(None); - } - } + if info.codecs.first() == Some(&"av1") + && crate::media::ffmpeg::video_capabilities() + .av1 + .decoders + .is_empty() + { + anyhow::bail!("VideoTranscode: installed FFmpeg has no AV1 decoder"); } let stem = src @@ -1574,13 +1564,8 @@ fn transcode_video_finalise( return Ok(JobExecution::NeedsCompletion); } Err(error) => { - tracing::warn!( - target: "workers", - post_id, - error = %error, - "VideoTranscode: output validation failed; keeping original media" - ); - return Ok(JobExecution::NeedsCompletion); + return Err(error) + .context("VideoTranscode: output validation failed; original retained") } } @@ -1757,22 +1742,7 @@ fn validate_transcoded_webm_output( }); } - if crate::media::probe::probe_stream_kind(output_path)? - != crate::media::probe::StreamKind::Video - { - return Ok(TranscodeOutputDecision::Skip { - reason: "transcoded output has no video stream", - }); - } - let output_str = output_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("Transcoded output path is non-UTF-8"))?; - let codec = crate::media::probe::probe_video_codec(output_str)?; - if codec != "vp9" { - return Ok(TranscodeOutputDecision::Skip { - reason: "transcoded output is not VP9", - }); - } + crate::media::probe::validate_webm_output_file(output_path)?; Ok(TranscodeOutputDecision::Accept) } @@ -4636,6 +4606,100 @@ mod tests { assert!(!tmp_path.exists()); } + #[tokio::test] + #[cfg(unix)] + async fn failed_video_process_cannot_promote_partial_output() -> anyhow::Result<()> { + use super::{install_deterministic_output, run_video_transcode}; + use tokio::process::Command as TokioCommand; + use tokio_util::sync::CancellationToken; + let dir = tempfile::tempdir()?; + let source = dir.path().join("original.mp4"); + std::fs::write(&source, b"original media")?; + let destination = dir.path().join("converted.webm"); + for (script, cancel) in [ + ( + "printf partial > \"$1\"; echo 'AV1 decode failed' >&2; exit 1", + CancellationToken::new(), + ), + ( + "printf partial > \"$1\"; sleep 30", + CancellationToken::new(), + ), + ("printf partial > \"$1\"; sleep 30", { + let cancel = CancellationToken::new(); + cancel.cancel(); + cancel + }), + ] { + let tmp = tempfile::Builder::new() + .suffix(".webm") + .tempfile_in(dir.path())?; + let temporary_path = tmp.path().to_owned(); + let mut command = TokioCommand::new("/bin/sh"); + command.process_group(0); + command + .args(["-c", script, "video-fixture"]) + .arg(tmp.path()); + // The same process gate used by production must reject promotion. + let result = run_video_transcode(command, 1, 1, cancel) + .await + .and_then(|()| install_deterministic_output(tmp, &destination)); + anyhow::ensure!(result.is_err(), "failed video process accepted"); + anyhow::ensure!(!temporary_path.exists(), "partial output leaked"); + anyhow::ensure!(!destination.exists(), "partial output promoted"); + anyhow::ensure!( + std::fs::read(&source)? == b"original media", + "source changed" + ); + } + Ok(()) + } + + #[test] + fn invalid_transcode_output_is_not_promoted_or_recorded() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let source = dir.path().join("original.mp4"); + std::fs::write(&source, vec![0; 16_384])?; + let destination = dir.path().join("converted.webm"); + let pool = crate::db::init_test_pool()?; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); + for name in [ + "av1.webm", + "av1.mkv", + "av1.mp4", + "vp8.webm", + "video.mp4", + "audio.webm", + ] { + let tmp = tempfile::Builder::new() + .suffix(".webm") + .tempfile_in(dir.path())?; + let temporary_path = tmp.path().to_owned(); + std::fs::copy(root.join(name), tmp.path())?; + let result = transcode_video_finalise( + 1, + 1, + "b/original.mp4", + &source, + &destination, + "b/converted.webm", + tmp, + &pool, + ); + anyhow::ensure!(result.is_err(), "invalid output {name} accepted"); + anyhow::ensure!( + !temporary_path.exists() && !destination.exists(), + "invalid output leaked" + ); + let conn = pool.get()?; + anyhow::ensure!( + file_hash_count(&conn, "b/converted.webm") == 0, + "invalid output persisted" + ); + } + Ok(()) + } + #[test] fn webm_reencode_uses_distinct_output_name() { assert_eq!(transcoded_webm_name("clip", "mp4"), "clip.webm"); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 9276f160..aaa7669e 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -422,8 +422,9 @@ RUSTCHAN_E2E_FFMPEG_PATH=/path/to/ffmpeg \ npm run test:e2e:media ``` -The video pass requires FFmpeg and the `libwebp`, `libvpx-vp9`, and -`libopus` encoders. PDF previews use the built-in Rust renderer or SVG fallback. +The video pass requires FFmpeg with PNG frame extraction, the `libvpx-vp9` and +`libopus` encoders, and the WebM muxer. WebP encoding (including video previews) +uses Rust and does not require an FFmpeg WebP encoder. PDF previews use the built-in Rust renderer or SVG fallback. Common audio waveforms, image conversion and metadata inspection also run in the default harness with FFmpeg unavailable. diff --git a/tests/e2e/auto-compress.spec.ts b/tests/e2e/auto-compress.spec.ts index 99810f34..0720d6f2 100644 --- a/tests/e2e/auto-compress.spec.ts +++ b/tests/e2e/auto-compress.spec.ts @@ -347,12 +347,16 @@ function requiredMediaToolchainSkipReason(): string | undefined { return `ffprobe is required for auto-compress live fixtures: ${ffprobe}`; } const encoders = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], { encoding: 'utf8' }); - const output = `${encoders.stdout}\n${encoders.stderr}`; - for (const encoder of ['libwebp', 'libvpx-vp9', 'libopus']) { - if (!output.includes(encoder)) { + const names = (encoders.stdout ?? '').split('\n').map((line) => line.trim().split(/\s+/)).filter(([flags, name]) => /^[VA][A-Z.]{5}$/.test(flags ?? '') && name !== '=').map(([, name]) => name); + for (const encoder of ['png', 'libvpx-vp9', 'libopus']) { + if (encoders.status !== 0 || !names.includes(encoder)) { return `ffmpeg encoder ${encoder} is required for auto-compress live verification`; } } + const muxers = spawnSync(ffmpeg, ['-hide_banner', '-muxers'], { encoding: 'utf8' }); + if (muxers.status !== 0 || !(muxers.stdout ?? '').split('\n').some((line) => /^\s*E\s+webm\s/.test(line))) { + return 'ffmpeg WebM muxer is required for auto-compress live verification'; + } return undefined; } diff --git a/tests/e2e/media-toolchain-check.mjs b/tests/e2e/media-toolchain-check.mjs index 74937533..52db43cc 100644 --- a/tests/e2e/media-toolchain-check.mjs +++ b/tests/e2e/media-toolchain-check.mjs @@ -3,7 +3,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const REQUIRED_ENCODERS = ['libwebp', 'libvpx-vp9', 'libopus']; +// FFmpeg extracts PNG frames; RustChan itself encodes their WebP thumbnails. +const REQUIRED_ENCODERS = ['png', 'libvpx-vp9', 'libopus']; if (process.argv.includes('--self-test')) { runSelfTest(); @@ -13,19 +14,8 @@ if (process.argv.includes('--self-test')) { function runRealCheck() { const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; - - const version = run(ffmpeg, ['-version']); - if (!version.ok) fail(`FFmpeg is required for npm run test:e2e:media but '${ffmpeg}' is not usable.\n${version.detail}`); - - const encoders = run(ffmpeg, ['-hide_banner', '-encoders']); - if (!encoders.ok) fail(`Could not inspect FFmpeg encoders for '${ffmpeg}'.\n${encoders.detail}`); - - const missing = REQUIRED_ENCODERS.filter((encoder) => !encoders.stdout.includes(encoder)); - if (missing.length > 0) { - fail(`FFmpeg is missing required encoder support for the media E2E pass: ${missing.join(', ')}.`); - } - - + const errors = checkToolchain(ffmpeg); + if (errors.length > 0) fail(errors.join('\n')); } function runSelfTest() { @@ -45,6 +35,11 @@ function runSelfTest() { fs.chmodSync(noCodecs, 0o755); const codecErrors = checkToolchain(noCodecs); assert(codecErrors.some((message) => message.includes('missing required encoder')), 'missing codecs should be visible'); + + const nativeWebp = path.join(temp, 'ffmpeg-video-only'); + fs.writeFileSync(nativeWebp, '#!/bin/sh\nif [ "$1" = "-version" ]; then exit 0; fi\nif [ "$2" = "-muxers" ]; then printf " E webm WebM\\n"; exit 0; fi\nprintf " V..... png PNG\\n V..... libvpx-vp9 VP9\\n A..... libopus Opus\\n"\n'); + fs.chmodSync(nativeWebp, 0o755); + assert(checkToolchain(nativeWebp).length === 0, 'video pass must not require an FFmpeg WebP encoder'); } finally { fs.rmSync(temp, { recursive: true, force: true }); } @@ -55,9 +50,14 @@ function checkToolchain(ffmpeg) { if (!run(ffmpeg, ['-version']).ok) errors.push(`FFmpeg '${ffmpeg}' is not usable.`); const encoders = run(ffmpeg, ['-hide_banner', '-encoders']); if (encoders.ok) { - const missing = REQUIRED_ENCODERS.filter((encoder) => !encoders.stdout.includes(encoder)); + const names = encoders.stdout.split('\n').map((line) => line.trim().split(/\s+/)).filter(([flags, name]) => /^[VA][A-Z.]{5}$/.test(flags ?? '') && name !== '=').map(([, name]) => name); + const missing = REQUIRED_ENCODERS.filter((encoder) => !names.includes(encoder)); if (missing.length > 0) errors.push(`FFmpeg is missing required encoder support: ${missing.join(', ')}.`); + } else { + errors.push(`Could not inspect FFmpeg encoders: ${encoders.detail}`); } + const muxers = run(ffmpeg, ['-hide_banner', '-muxers']); + if (!muxers.ok || !muxers.stdout.split('\n').some((line) => /^\s*E\s+webm\s/.test(line))) errors.push('FFmpeg is missing the WebM muxer.'); return errors; } diff --git a/tests/e2e/media-toolchain.spec.ts b/tests/e2e/media-toolchain.spec.ts index a94747e6..f4c75d96 100644 --- a/tests/e2e/media-toolchain.spec.ts +++ b/tests/e2e/media-toolchain.spec.ts @@ -1,10 +1,14 @@ import type { Page, TestInfo } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; import { expectNoHorizontalOverflow } from './phase4-helpers'; import { createThread, expect, expectSafePage, expectSafeResponse, + sqliteQuery, test, updateBoardSettings, } from './helpers'; @@ -12,6 +16,51 @@ import { test.skip(process.env.RUSTCHAN_E2E_MEDIA_TOOLCHAIN !== '1', 'opt-in real media toolchain pass only'); test.describe('real media toolchain', () => { + test('AV1 in WebM, Matroska and MP4 converts to verified VP9 WebM', async ({ page, app }) => { + test.setTimeout(180_000); + const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; + const ffprobe = process.env.RUSTCHAN_E2E_FFPROBE_PATH ?? 'ffprobe'; + const decoders = spawnSync(ffmpeg, ['-hide_banner', '-decoders'], { encoding: 'utf8' }); + test.skip(decoders.status !== 0 || !/\b(?:av1|libdav1d|libaom-av1)\b/.test(decoders.stdout ?? ''), 'installed FFmpeg lacks an AV1 decoder'); + test.skip(spawnSync(ffprobe, ['-version'], { stdio: 'ignore' }).status !== 0, 'optional FFprobe oracle is unavailable'); + const media = path.resolve(__dirname, '../fixtures/media'); + for (const ext of ['webm', 'mkv', 'mp4']) { + const input = path.join(app.fixtureDir, `av1-input.${ext}`); + const args = ['-hide_banner', '-loglevel', 'error', '-i', path.join(media, `av1.${ext}`)]; + // Add AAC only to MP4 to verify conversion to WebM-compatible Opus audio. + if (ext === 'mp4') args.push('-i', path.join(media, 'tone.m4a'), '-map', '0:v:0', '-map', '1:a:0'); + // Harmless source metadata makes the result smaller under the existing size policy. + args.push('-c', 'copy', '-metadata', `comment=${'fixture padding '.repeat(512)}`, '-y', input); + const fixture = spawnSync(ffmpeg, args, { encoding: 'utf8', timeout: 30_000 }); + expect(fixture.status, fixture.stderr).toBe(0); + await createThread(page, app, 'vid', { + subject: `AV1 ${ext}`, body: 'AV1 container and codec conversion regression', filePath: input, + }); + await expect.poll(async () => { + await page.reload(); + await expectSafePage(page); + return page.locator('.file-info a').first().getAttribute('href'); + }, { timeout: 90_000, intervals: [1_000, 2_000] }).toMatch(ext === 'webm' ? /\.vp9\.webm$/ : /\.webm$/); + const href = await page.locator('.file-info a').first().getAttribute('href'); + const response = await page.request.get(`${app.baseURL}${href}`); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('video/webm'); + const output = path.join(app.fixtureDir, `verified-${ext}.webm`); + await fsp.writeFile(output, await response.body()); + const probe = spawnSync(ffprobe, ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], { encoding: 'utf8', timeout: 30_000 }); + expect(probe.status, probe.stderr).toBe(0); + const info = JSON.parse(probe.stdout); + const video = info.streams.find((stream: { codec_type: string }) => stream.codec_type === 'video'); + expect(video.codec_name).toBe('vp9'); + expect(video.pix_fmt).toBe('yuv420p'); + expect([video.width, video.height]).toEqual([64, 64]); + expect(Number(info.format.duration)).toBeGreaterThan(0); + expect(info.streams.filter((stream: { codec_type: string }) => stream.codec_type === 'audio').map((stream: { codec_name: string }) => stream.codec_name)).toEqual(ext === 'mp4' ? ['opus'] : []); + expect(sqliteQuery(app, "SELECT mime_type || '|' || media_processing_state || '|' || COALESCE(media_processing_error, '') FROM posts ORDER BY id DESC LIMIT 1;")).toBe('video/webm||'); + expect(Number(sqliteQuery(app, 'SELECT file_size FROM posts ORDER BY id DESC LIMIT 1;'))).toBe((await fsp.stat(output)).size); + } + }); + test('uploads use real thumbnails, transcodes, waveform previews, and PDF fallback safely', async ({ page, app, javaScriptEnabled }, testInfo) => { test.setTimeout(180_000); const files = app.fixtures(); diff --git a/tests/fixtures/media/README.md b/tests/fixtures/media/README.md index 05b75b5d..9153a504 100644 --- a/tests/fixtures/media/README.md +++ b/tests/fixtures/media/README.md @@ -29,3 +29,12 @@ The `opus-surround-distinct.opus` fixture contains six generated sine waves at 2 `speex-chained-multiplexed.spx` joins two complete multiplexed groups with four distinct serials and recomputed CRCs. Completed logical streams must not block the next chain’s grouped BOS pages. `speex-stereo.spx` is Debian FFmpeg 5.1.9/libspeex encoding of the synthetic stereo `tone.m4a`, resampled to 32 kHz (`-ar 32000 -ac 2 -c:a libspeex`). Together with mono `tone.spx`, it protects count-preserving mono/stereo layout negotiation before the legacy Debian waveform filter. + +`av1.webm`, `av1.mkv`, `av1.mp4`, and `vp8.webm` contain only a generated 64 × 64 test pattern at 5 fps for 0.4 seconds, without audio. They were generated with FFmpeg 9.0.2/SVT-AV1 4.2.0; the AV1 packet stream was remuxed unchanged to Matroska and MP4. Tests probe these checked-in bytes without needing an installed AV1 encoder. The optional real conversion test needs only an AV1 decoder and the established VP9/Opus output pipeline. + +```sh +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=5:duration=0.4' -c:v libsvtav1 -preset 12 -crf 35 -threads 2 -an -y av1.webm +ffmpeg -i av1.webm -c copy -f matroska -y av1.mkv +ffmpeg -i av1.webm -c copy -y av1.mp4 +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=5:duration=0.4' -c:v libvpx -threads 2 -an -y vp8.webm +``` diff --git a/tests/fixtures/media/av1.mkv b/tests/fixtures/media/av1.mkv new file mode 100644 index 0000000000000000000000000000000000000000..b9f827446e45b8acd8f4bee69b0f169178d04bd1 GIT binary patch literal 2406 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 Q%=!LRSWW}AE#Za!32>#$+yDRo literal 0 HcmV?d00001 From 2ad437ea394e2eb1ba949de8a725e230907465f4 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 13:59:07 -0700 Subject: [PATCH 07/11] Improve SQLite connection ownership and measured query performance --- docs/sqlite-engineering.md | 440 +++++ docs/sqlite-evidence/acceptance-matrix.json | 1875 +++++++++++++++++++ docs/sqlite-evidence/baseline.json | 1391 ++++++++++++++ docs/sqlite-evidence/final-matrix.json | 1875 +++++++++++++++++++ docs/sqlite-evidence/index-profile.json | 240 +++ docs/sqlite-evidence/media-baseline.json | 89 + docs/sqlite-evidence/media-optimized.json | 182 ++ docs/sqlite-evidence/optimized-repeat.json | 1453 ++++++++++++++ docs/sqlite-evidence/optimized.json | 1453 ++++++++++++++ docs/sqlite-evidence/query-profile.json | 286 +++ src/db/diagnostics.rs | 146 ++ src/db/mod.rs | 5 + src/db/pool.rs | 135 +- src/db/posts.rs | 82 +- src/db/schema.rs | 8 +- src/db/threads.rs | 239 ++- src/db/workload.rs | 857 +++++++++ src/handlers/admin/backup/create.rs | 19 +- src/handlers/board/create_thread.rs | 6 +- src/handlers/mod.rs | 30 +- src/handlers/posting.rs | 580 +++++- src/handlers/posting/performance.rs | 200 ++ src/handlers/thread.rs | 6 +- src/media/prune.rs | 71 +- src/pending_fs.rs | 62 +- src/server/server.rs | 48 +- src/server/server/assets.rs | 12 +- src/server/server/observability.rs | 28 + tests/e2e/audit-nojs-parity.spec.ts | 15 +- 29 files changed, 11615 insertions(+), 218 deletions(-) create mode 100644 docs/sqlite-engineering.md create mode 100644 docs/sqlite-evidence/acceptance-matrix.json create mode 100644 docs/sqlite-evidence/baseline.json create mode 100644 docs/sqlite-evidence/final-matrix.json create mode 100644 docs/sqlite-evidence/index-profile.json create mode 100644 docs/sqlite-evidence/media-baseline.json create mode 100644 docs/sqlite-evidence/media-optimized.json create mode 100644 docs/sqlite-evidence/optimized-repeat.json create mode 100644 docs/sqlite-evidence/optimized.json create mode 100644 docs/sqlite-evidence/query-profile.json create mode 100644 src/db/diagnostics.rs create mode 100644 src/db/workload.rs create mode 100644 src/handlers/posting/performance.rs diff --git a/docs/sqlite-engineering.md b/docs/sqlite-engineering.md new file mode 100644 index 00000000..93510c7b --- /dev/null +++ b/docs/sqlite-engineering.md @@ -0,0 +1,440 @@ +# SQLite engineering pass + +## Baseline audit (2026-10-02, before production changes) + +The starting tree was clean. Rust 1.99.0, rusqlite 0.39.0 (bundled SQLite, +backup support), r2d2 0.8.10 and r2d2_sqlite 0.34.0 are authoritative. No +backend replacement, extra service, new dependencies, or commits were introduced. + +### Ownership and atomicity map + +| Area | Baseline implementation | Ownership / correctness boundary | +| --- | --- | --- | +| Startup | `db/pool.rs`, `db/schema.rs`, `db/migrations.rs` | Every connection gets WAL, NORMAL, foreign keys, -32000 KiB cache, MEMORY temp store, 64 MiB mmap and 1000 ms busy timeout. Configurable pool defaults to 8; checkout timeout is 1 second. Schema installation/repair is transactional and structural verification rejects unexpected objects. | +| Thread / reply HTTP | `handlers/board/create_thread.rs`, `handlers/thread.rs`, `handlers/posting.rs` | Blocking workers hold a media semaphore through submission. A connection is checked out before submission and held through identity/body preparation, media probing/hash/thumbnail generation, DB commit, upload promotion, job enqueueing and configured pruning. | +| Thread / reply writes | `db/threads.rs` | BEGIN IMMEDIATE serializes token replay, dedup revalidation, thread/OP or reply/count updates, optional poll and filesystem intent. Reply locked/archived state is checked again under the write lock. Commit failures roll back before return. | +| Upload / dedup | `handlers/mod.rs`, `pending_fs.rs`, `db/fs_ops.rs` | Staged uploads get content hashes and a durable promotion record in the post transaction. Dedup reuse is board-scoped and checked again against current DB references. Promotion/replay retains the intent on failure. | +| Polls / moderation | `db/posts.rs`, `db/admin.rs`, `db/accounts.rs`, `db/boards.rs` | Unique poll voter and one-OP indexes plus schema domain/relationship triggers protect persisted invariants. Edit/delete/account mutations use immediate transactions when read-validation-write must serialize. | +| Jobs | `workers/mod.rs`, `db/posts.rs` | Atomic UPDATE RETURNING claim; bounded attempts; durable media state transitions, retry and interruption recovery. Heavy media prepare/render runs outside final completion transactions. Persistence retries handle pool/busy failures. | +| Pruning / filesystem deletion | `media/prune.rs`, `db/threads.rs`, `pending_fs.rs` | Short intent creation followed by revalidated finalization. Some finalization transactions intentionally include filesystem checks/deletion to exclude racing reference creation; removing these without a replacement protocol is unsafe. | +| Reconciliation | `media/reconcile.rs` | Consistent DB reference snapshot; filesystem inventory outside its read transaction, but the same connection stays borrowed. `data_version` comparisons are connection-local; checking on a newly borrowed connection is not equivalent. Repairs revalidate each candidate inside an immediate transaction. | +| Backups | `handlers/admin/backup/create.rs`, `downloads.rs` | VACUUM INTO includes committed WAL state. Full backup keeps its live pooled connection through file copies, hashing and manifest creation until late in the operation. Maintenance backup also retains it through packaging. | +| Restores | `handlers/admin/backup/restore_full.rs`, `restore_board.rs` | Preflight, snapshot, staged swaps, durable recovery markers and rollback preserve DB/filesystem coordination. Board restore holds a write transaction through its coordinated restore; restructure only with specific recovery evidence. | +| Updates | `updates/transaction.rs`, `updates/snapshot.rs` | Stopped-service VACUUM INTO and verified persistent-state inventory; transaction journal and interrupted-update recovery. No live pool is needed by the isolated updater. | +| FTS / repair | `db/schema.rs`, `db/admin.rs` | FTS5 external-content table and insert/update/delete triggers; explicit rebuild during repair. Backup precedes repair. Structural verification plus quick/integrity and foreign-key checks protect startup/restore. | +| WAL | `db/admin.rs`, maintenance handlers | SQLite default automatic checkpoint (1000 pages); operator TRUNCATE checkpoint reports busy/log/checkpointed. A recurring TRUNCATE checkpoint plus PRAGMA optimize runs hourly by default, staggered and skipped while requests/uploads/maintenance are busy; these two statements originally ran on an async worker. | + +### Findings, ranked + +1. **Avoidable pool occupancy during media preparation.** A scarce pooled + connection remains owned during independent expensive work. The dedup query + is the only DB access required by the primary-upload processor. +2. **Posting state can become stale.** Board access preflight occurs before + multipart parsing; final DB writes do not recheck board access, bans or + cooldown. The bump decision uses the pre-processing reply count and board + bump limit, permitting an extra bump after concurrent replies cross the + limit. Locked/archived reply state is already correctly revalidated. +3. **Board listing aggregates before pagination.** The thread SELECT joins all + replies, groups all matching board threads, then orders and limits. Even a + ten-thread page visits replies of threads outside that page. Profile before + changing it; retain exact preview/count/latest-post semantics. +4. **Backups retain live connections during packaging.** Snapshot-based metadata + extraction can potentially release the live pool earlier and also make + metadata correspond to the actual snapshot. +5. **Limited contention attribution.** Pool/busy errors map to retryable 503 + but there is little timing evidence distinguishing checkout, hold time, + write-lock acquisition and transaction work. +6. **Reconciliation lifetime is deliberate.** Its generation check requires + the same SQLite connection. Do not shorten ownership by simply reacquiring + and comparing `data_version` values from different connections. + +Existing hot reads and job claiming already use cached prepared statements; +preview posts and thread-file collection already batch work. File-reference +OR queries have partial indexes on all three paths. Schema repair already +removes two historical duplicate indexes. Extra indexes will require populated +query-plan/timing evidence and the existing additive migration path. + +### Durability interpretation + +WAL + NORMAL preserves consistency and recovery after application crashes; +some acknowledged transactions may be lost after power loss or OS failure. +FULL adds a WAL sync per commit for stronger commit durability when the storage +stack honors sync. Neither setting fixes faulty storage or application-level +filesystem coordination. Actual power-loss behavior cannot be established by +killing an application process. macOS fullfsync is a distinct setting, so a +local FULL timing alone must not be portrayed as a universal hardware guarantee. +See [SQLite synchronous](https://sqlite.org/pragma.html#pragma_synchronous) and +[WAL](https://sqlite.org/wal.html). Pool/cache/durability defaults remain unchanged +until representative measurements support a change. + +## 1. Baseline architecture + +The audit above records the original connection, transaction, filesystem and +recovery boundaries. SQLite remains bundled and embedded; no new dependencies, +services, unsafe code, database abstraction or backend were introduced. The +pool remains configurable through settings and `CHAN_DB_POOL_SIZE`. + +## 2. Problems found + +The highest-impact measured issue was repeated FTS **prefix** lookups per board +post: a common-term count took 11.2 seconds on the populated fixture. Uploads +held connections through independent processing and nested spam-job checkouts +could exhaust a two-connection pool. Board pages aggregated unrequested replies. +Posting cooldown, access, ban and bump decisions could become stale before the +write lock. Backups held live connections through packaging. Recurring session +purges and checkpoint/optimize calls ran directly on async workers; checkpoint +logs mislabeled SQLite's `busy` flag as pages backfilled. + +## 3. Changes implemented + +- Posting borrows for initial reads, dedup lookup, atomic insertion, promotion + metadata and job persistence independently. Media probing, hashes, encoding, + staged-file promotion and candidate filesystem scans release the pool. +- Transactional revalidation checks current board identity/access, admin session, + bans, cooldown, CAPTCHA/media/body policy and word filters. Changed preparation + policy returns a recoverable conflict; locked/archived threads remain rejected. +- Bump decisions use the current reply count and current board limit under the + immediate write lock, including simultaneous replies at the boundary. +- Thread pages materialize the ordered page before indexed per-thread aggregates. + Missing OPs are excluded before pagination; preview, image count and latest-post + semantics are covered against the original query, including timestamp ties + (active IDs descend, archived IDs ascend). +- FTS drives the prefix-result/count joins; cached statements retain board scope, + sanitization, ordering and pagination. No adaptive strategy remains. +- Two measured indexes use the existing transactional additive repair path: + `(thread_id,id)` for polling and `(board_id,ip_hash,created_at DESC)` for cooldown. +- Full and maintenance backup packaging reads the exact read-only VACUUM snapshot + after releasing the live connection. Authorization is rechecked before snapshot. +- Recurring checkpoint/optimize and session cleanup use blocking workers; checkpoint + logs now report `log_pages`, `checkpointed_pages` and `busy` accurately. +- Thresholded pool/SQL/transaction tracing, pool/file-size metrics and opt-in + offline workload, actual-upload and crash tests provide reproducible evidence. + +## 4. Connection lifetime + +| Phase | Before | After | +| --- | --- | --- | +| Initial validation | One submission-wide checkout | Scoped reads; release before independent preparation | +| Hash / decode / thumbnail | Live connection held | No connection; one scoped dedup lookup | +| Post commit | Same connection | Late checkout; revalidation and atomic commit; immediate release | +| Upload promotion / replay | Same connection through moves / verification | Filesystem work first; short metadata transaction only | +| Job persistence | Live checkout plus queue's nested checkout | Independent media-job scope; release before spam queue checkout | +| Media pruning | Connection held while inventory paths are checked | Collect rows; release; validate paths; borrow per mutation boundary | +| Full backup | Live checkout through packaging | Live VACUUM checkout only; standalone snapshot connection during packaging | + +The one-connection regression acquires the sole connection inside independent +preparation and successfully schedules work after submission. Existing recovery +and deletion protocols retain filesystem I/O inside transactions where a racing +new reference must be excluded. Reconciliation deliberately retains one connection +for `data_version`: comparisons across different connections are invalid. + +## 5. Transaction boundaries + +Posting retains BEGIN IMMEDIATE because token replay, policy checks, dedup paths, +thread flags, counters, bump limits, OP/poll rows and durable filesystem/prune +intents require one serialized decision. Idempotent replay precedes revalidation; +preparation from a losing request is cleaned without creating another post. No +media command/encoding runs inside these transactions. Existing commit rollback +handling is preserved, and typed validation errors survive the boundary. + +Upload finalization still commits hash metadata, optional-thumbnail repair and +intent removal together after promotion. Original-file pruning and deletion keep +their race-exclusion transaction. Restores/updater recovery protocols were audited +and retained. VACUUM INTO necessarily occupies its connection while SQLite makes +the consistent snapshot; independent packaging no longer occupies the live pool. + +## 6. PRAGMA decisions + +The production default changes from NORMAL to **FULL**. Across three optimized +runs, the pool-eight throughput medians show roughly 2%/6%/12.5% cost for +read-heavy/burst/mixed work. Actual media posting remains dominated by processing +CPU. This is a reasonable cost for stronger acknowledged-commit durability when +storage honors sync; it does not improve every failure mode or replace upload +recovery. The connection pool remains eight, configurable as before. + +| Setting | Value | Rationale | +| --- | --- | --- | +| journal_mode | WAL | Readers can proceed alongside one writer; committed WAL state is included in snapshots. | +| synchronous | FULL, every pooled connection | WAL sync at commit; modest measured cost. Process-crash recovery does not establish power-loss guarantees. | +| foreign_keys | ON, every pooled connection | Relationship constraints are connection-local and must remain enforced. | +| cache_size | -32000 | 32,000 KiB target per connection; allocated on demand, roughly 250 MiB across eight fully populated caches. No evidence supports enlarging it. | +| temp_store | MEMORY | Existing bounded page/sort workloads; removed large pre-page aggregates rather than enlarging memory settings. | +| mmap_size | 67108864 | Existing 64 MiB mapping ceiling; virtual/shared mapping is distinct from private per-connection cache. | +| busy_timeout | 1000 ms | Bounded write-lock wait; overload remains retryable rather than hanging. | +| wal_autocheckpoint | SQLite default 1000 pages | Retained alongside existing idle scheduled/operator checkpoints. | + +Pool checkout timeout remains one second. Pool warnings begin at 250 ms and are +rate-limited independently per category to one per 30 seconds per pool. Detailed +SQL, lock-wait and posting transaction timings require `db=debug`, use fixed +operation labels, and contain no SQL parameters or private request content. +Metrics add pool total/idle connections and physical DB/WAL bytes; `-1` means +unavailable. File-size sampling uses async filesystem APIs, and existing metrics +access control and deep schema-health semantics are unchanged. + +## 7. Pool-size findings + +Eight remains the default. Four sometimes achieves higher throughput and lower +p99, but eight greatly reduces checkout waiting and provides useful headroom for +background jobs. Twelve and sixteen do not consistently improve throughput or +latency; extra caches and competing writers make enlargement unjustified. + +Median operations/second across three optimized runs, with eight workers: + +| Pool | NORMAL read / burst / mixed | FULL read / burst / mixed | +| ---: | ---: | ---: | +| 2 | 3545 / 1861 / 2757 | 3598 / 1734 / 2734 | +| 4 | 5567 / 1900 / 3274 | 5699 / 1782 / 3018 | +| 8 | 4953 / 1914 / 3458 | 4854 / 1794 / 3027 | +| 12 | 5209 / 1878 / 3344 | 5092 / 1816 / 3030 | +| 16 | 4956 / 1894 / 3486 | 5064 / 1827 / 3171 | + +In the third run FULL-eight checkout p95 was 3–5 microseconds versus roughly +3–8 milliseconds at pools two/four; 0–1 of 3200 checkouts exceeded 100 microseconds +at eight. This is a measurable-wait proxy, not a claim that every shorter +checkout had no wait. The first FULL-eight read run was a 2496 ops/s outlier; +repeats reached 4854 and 5546. Raw runs retain that variation. + +## 8. Query-plan/index findings + +SQLite 3.51.3, eight boards, 800 threads and 40,000 posts; every query is prepared +against the populated real schema. Twenty-five cached repetitions, nearest-rank +percentiles, with result-ID/order equivalence checks. Times below are medians. + +| Production query | Before | After | Evidence | +| --- | ---: | ---: | --- | +| Board page, 10 threads | 5.372 ms | 0.144 ms | MATERIALIZE page; indexed OP lookup; bounded reply aggregates; removes temp GROUP BY and DISTINCT | +| Rare prefix search, full posts | 74.174 ms | 0.434 ms | One FTS scan, primary-key post lookups | +| Common prefix search, full posts | 56.798 ms | 13.300 ms | Preserved order/limit; eliminates repeated prefix evaluation | +| Missing prefix search, full posts | 38.956 ms | 0.076 ms | Empty FTS result exits before board scan | +| Rare prefix count | 157.500 ms | 0.258 ms | FTS-driven count | +| Common prefix count | 11241.770 ms | 8.623 ms | FTS-driven count | +| Missing prefix count | 38.762 ms | 0.010 ms | FTS-driven count | +| Live polling | 2.749 ms | 0.005 ms | thread/id range index; no order sort | +| Cooldown lookup | 0.154 ms | 0.002 ms | Covering board/IP/timestamp index | + +Exact-term exploratory measurements initially showed a limited-common-term +regression for an FTS-first join. RustChan actually uses sanitized quoted prefix +terms. The final acceptance comparison uses that **actual syntax** and complete +post projections; all three term classes improve. The rejected adaptive probe is +absent from production. CROSS JOIN explicitly preserves the measured join order; +see [SQLite's optimizer guidance](https://sqlite.org/optoverview.html#manual_control_of_query_plans_using_cross_join). + +Two indexes added 1,302,528 bytes (~10.7%) after compacting the dropped-index +fixture: 12,210,176 → 13,512,704 bytes. Isolated FULL write median/p95 changed +384/510 → 404/543 microseconds (~5%/~6.5%); the mixed/burst matrix is the +acceptance gate. Existing +thread/timestamp, cross-board IP and path indexes remain necessary. Historical +redundant indexes were already removed by repair; no speculative new latest-thread, +admin or dedup index was added. Reports, bans, jobs, path OR checks, file hashes +and admin logs already use useful indexes. Full backup/integrity inventories +necessarily scan their complete snapshot/data; metrics/admin deep checks remain +intentional expensive diagnostics. + +## 9. WAL/checkpoint findings + +The baseline sustained matrix retained WAL files up to ~174 MB while all final +PASSIVE checkpoints reported `busy=0` and backfilled all current frames. Physical +WAL bytes are a reusable high-water allocation, not a count of currently live +frames. The third optimized run retained at most 14.3 MB of physical WAL and completed +PASSIVE checkpoints in at most 3.263 ms, all with busy=0 and complete backfill. +The final acceptance run reached at most 15.80 MB WAL, +5.235 ms checkpoint latency and 123.1 MiB case-end RSS; +all final checkpoints completed with busy=0. No evidence supports another +checkpoint manager or changing the existing policy. + +A held read snapshot deterministically prevents complete TRUNCATE (`busy=1`, +checkpointed < log); a concurrent VACUUM snapshot still includes later committed +boards. Releasing the reader permits complete TRUNCATE. Normal WAL readers remain +usable during an uncommitted writer; another writer fails within the existing +busy bound and maps to retryable overload. No new checkpoint manager was added. + +## 10. Benchmark results + +The SQL matrix uses eight concurrent workers, 400 operations each, all pools +2/4/8/12/16, NORMAL/FULL and 5%/70%/25% write mixes: 96,000 operations per matrix. +Reads use production board/catalog/thread/polling/FTS/moderation/poll/dedup helpers. +Writes include replies, counters, token records, FTS triggers, poll votes, durable +job enqueue/claim/complete and job cleanup. Actual PNG submissions separately +exercise new-thread, filesystem-intent/promotion and job paths with two producers +and four readers. No network service, FFmpeg or new dependency is required. + +The tables below compare the original NORMAL/eight default with the final +FULL/eight default, thus including the durability cost. Total latency includes +checkout and, for writes, lock acquisition. Failed operations count in latency +but not successful throughput. The final acceptance matrix also verifies actual +non-OP row counts against every persisted thread reply counter. + +| Workload / default | Ops/s | Total p50 / p95 / p99, ms | Checkout p95, µs | Write wait p95, ms | Write hold p95, ms | Busy / timeout | RSS, MiB | WAL, MiB | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| read_heavy Before | 78 | 3.915 / 694.506 / 950.879 | 11 | 0.189 | 4.900 | 0 / 0 | 102.8 | 12.16 | +| read_heavy After | 5501 | 0.840 / 3.618 / 6.471 | 3 | 11.995 | 3.051 | 0 / 0 | 89.3 | 13.43 | +| write_burst Before | 224 | 2.070 / 57.696 / 807.621 | 5 | 4.654 | 3.199 | 0 / 0 | 90.5 | 165.82 | +| write_burst After | 1747 | 0.482 / 3.080 / 67.702 | 3 | 4.551 | 0.757 | 0 / 0 | 112.3 | 4.67 | +| mixed Before | 96 | 2.913 / 654.620 / 934.125 | 14 | 1.567 | 3.706 | 0 / 0 | 97.2 | 59.10 | +| mixed After | 3065 | 0.517 / 2.624 / 27.526 | 3 | 20.170 | 1.169 | 0 / 0 | 78.1 | 7.30 | + +| Final FULL-eight workload | Read p50 / p95 / p99, ms | Write p50 / p95 / p99, ms | CPU, seconds | Checkpoint, ms | Checkpoint busy / log / backfilled | +| --- | ---: | ---: | ---: | ---: | --- | +| read_heavy | 0.821 / 3.438 / 4.272 | 2.501 / 12.846 / 50.634 | 3.50 | 0.498 | 0 / 43 / 43 | +| write_burst | 0.423 / 1.680 / 1.964 | 0.488 / 5.043 / 108.172 | 1.70 | 2.699 | 0 / 763 / 763 | +| mixed | 0.419 / 1.735 / 1.969 | 0.634 / 20.784 / 95.912 | 1.92 | 2.054 | 0 / 695 / 695 | + +All 96,000 final acceptance operations succeeded across 30 cases: zero busy +errors and zero pool timeouts. Every case passed integrity, foreign keys, FTS, +schema and reply-counter checks. Across all four optimized matrices there was +one bounded busy error in 384,000 operations (the third run's experimental +NORMAL-eight burst); all FULL cases were error-free. The original 96,000-operation +matrix also had zero busy errors/timeouts. This supports the chosen default, +without claiming that experimental configurations never encounter saturation. + +Write-lock **hold** p95 improves in all three default comparisons. Acquisition +wait grows in the much faster read-heavy/mixed workloads because more writes compete +per second; it is separately reported rather than hidden in transaction time. +The longest waits remain governed by the existing one-second busy timeout plus +scheduling overhead. Shortening holds cannot guarantee writer fairness. +Case-end maximum RSS increased +from 102.8 to 123.1 MiB across the full matrix (~20 MiB), with two additional +indexes and more useful cached data. Observed RSS stayed within that range; +these case-end samples do not establish a peak-memory ceiling. + + +Actual upload ownership evidence (milliseconds, 8 PNG posts per case): + +| Pool | Before hold p95 / max | After FULL hold p95 / max | Reader checkout p95, before → FULL after | Pool timeouts, before → after | +| ---: | ---: | ---: | ---: | ---: | +| 2 | 1382.262 / 1382.262 | 1.626 / 4.428 | 374.829 → 0.023 | 20 → 0 | +| 4 | 357.447 / 357.447 | 1.991 / 5.394 | 0.058 → 0.005 | 0 → 0 | +| 8 | 695.155 / 695.155 | 1.670 / 2.451 | 0.047 → 0.005 | 0 → 0 | + +All upload cases created exactly eight posts, finalized every durable upload +intent, and had no pending filesystem records. The eight FULL upload posting +p50/p95 values are reported in raw evidence; processing dominates that timing. + +Raw evidence: [original matrix](sqlite-evidence/baseline.json), +[first optimized matrix](sqlite-evidence/optimized.json), +[second optimized matrix](sqlite-evidence/optimized-repeat.json), +[third optimized matrix](sqlite-evidence/final-matrix.json), +[final acceptance matrix](sqlite-evidence/acceptance-matrix.json), +[final query/index profile](sqlite-evidence/query-profile.json), +[exploratory index profile](sqlite-evidence/index-profile.json), +[original media](sqlite-evidence/media-baseline.json), +[final media](sqlite-evidence/media-optimized.json). The exploratory index profile's +exact-term FTS numbers do not represent production prefix syntax. CPU endpoints +in the first two optimized matrices include verification; use the last two +matrices for workload CPU comparisons. +The SQL matrix excludes HTTP and media CPU. PNG generation precedes timing; +production media processing is included. RSS is sampled at case end, not peak; +CPU time uses optional host `ps` and is available in the final optimized runs only; +timer endpoints exclude verification. +These are debug-build, shared-host measurements, not hardware-independent capacity +promises. Short media runs have only eight posts per case, so pool hold times +and timeout behavior carry more weight than their noisy posting percentiles. + +## 11. Durability/recovery results + +NORMAL and FULL both pass abrupt writer-process termination with 100 committed +replies plus an interrupted open transaction. Restart checks exact reply count, +integrity, foreign keys, synchronized FTS, autocommit/write-lock availability, +interrupted-job recovery and durable filesystem-intent replay. This verifies +application-process crash recovery, not storage power-cut durability. + +The full suite exercises repair/rebuild, insert/edit/delete/restore FTS, dedup +races, one OP, poll-voter uniqueness, failed commits, backup/restore, software-update +snapshots and interrupted updates. Browser validation covers manual/automated +backup, full restore, rejected corrupt restore, native updates/restarts, media, +moderation and seven browser/no-JS projects. + +## 12. Validation + +Formatting required a minimal correction to three existing comma placements in +`server/assets.rs`; no UI behavior changed. Four heavy harness cases per Rust +target are explicitly ignored for normal CI; bounded concurrency and parent crash +regressions run normally. No lint policy was weakened or suppression added. + +The seven-project browser matrix initially passed 104 applicable cases and failed +three copies of the same stale PNG-only assertion. The existing pipeline produced +lossless WebP, so the assertion now checks exact persisted MIME and PNG/WebP magic +bytes. The targeted seven-project rerun passed all three applicable cases; four +projects correctly skip that no-JS-only test. The other 75 matrix skips are +existing project applicability guards. No failing case was disabled. A separate +Chromium recovery/media/update suite passed 35 cases with three existing guards. +Together these are 142 passing browser executions. + +The initial full Rust run exposed a genuine listing tie-order regression, fixed +with active-descending/archive-ascending ID order and full-field equivalence +coverage. A later unrestricted parallel run had one configuration supervisor +lease test fail acquiring a released lease. Its isolated rerun and a complete +four-thread suite passed (1330 tests in each library/binary target, seven +integration/doc tests). The cause of that transient lease failure is not proven; +no lease behavior or assertion was weakened. The final unrestricted rerun instead +had one PDF fallback-format assertion fail; its isolated rerun passed, and the +complete suite was rerun with the four-thread limit documented in +`docs/media-capabilities.md`. Its transient cause is also unproven; the PDF +assertion/implementation were preserved. Final-tree command results follow. + +| Command / suite | Final result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| `cargo check --locked --workspace --all-targets --all-features` | Passed | +| `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed | +| `cargo test --locked --workspace --all-features` | Final unrestricted run: 1329 passed, 1 PDF assertion failed; isolated rerun passed; complete four-thread rerun below | +| `cargo test --locked --workspace --all-features -- --test-threads=4` | Passed: 1330 library + 1330 CLI + 5 CLI integration + 1 native UI fixture + 1 doc = 2667; 8 explicitly invoked heavy cases ignored in normal suite | +| `cargo deny --locked check` | Passed advisories, bans, licenses and sources; existing reviewed warnings | +| `python3 -m unittest discover -s tools -p test_update_package.py` | 7 passed | +| `cargo test --locked --lib db:: -- --test-threads=4` | Focused DB regressions passed before final suite | +| `cargo test --locked --lib server::handlers::posting::tests:: -- --test-threads=4` | Posting policy/race/idempotency regressions passed before final suite | +| Ignored SQL matrix / query profile / PNG harness commands below | All passed with schema/integrity/FK/FTS checks; bounded experimental busy result documented | +| Chromium 11-spec posting/media/backup/maintenance/update suite | 35 passed, 3 applicability skips | +| Seven-project posting/password/no-JS/moderation matrix | 104 passed; 3 MIME assertion failures corrected, targeted rerun 3 passed; 75 applicability skips | +| Seven-project corrected upload-format case | 3 passed, 4 applicability skips | +| `git diff --check` | Passed | + +Browser commands (existing repository Playwright harness): + +```sh +RUSTCHAN_UPLOAD_REGRESSION_E2E=1 RUSTCHAN_AUDIT_OUTPUT=output/playwright/sqlite-engineering/chromium npx playwright test tests/e2e/posting-thread.spec.ts tests/e2e/submission-idempotency.spec.ts tests/e2e/upload-validation.spec.ts tests/e2e/upload-regressions.spec.ts tests/e2e/non-video-media.spec.ts tests/e2e/password-boards.spec.ts tests/e2e/moderation-phase2.spec.ts tests/e2e/backup-restore.spec.ts tests/e2e/maintenance-backup-phase2.spec.ts tests/e2e/software-updates.spec.ts tests/e2e/settings-restart.spec.ts --project=chromium --workers=2 --max-failures=5 +RUSTCHAN_AUDIT_OUTPUT=output/playwright/sqlite-engineering/matrix npx playwright test tests/e2e/posting-thread.spec.ts tests/e2e/password-boards.spec.ts tests/e2e/audit-nojs-parity.spec.ts tests/e2e/moderation-phase2.spec.ts --workers=2 --max-failures=5 +RUSTCHAN_E2E_SKIP_BUILD=1 RUSTCHAN_AUDIT_OUTPUT=output/playwright/sqlite-engineering/upload-format npx playwright test tests/e2e/audit-nojs-parity.spec.ts --grep 'file-upload path stores and serves' --workers=2 +``` + +Changed files: + +| Files | Purpose | +| --- | --- | +| `src/db/{mod,pool,diagnostics,workload}.rs` | FULL per connection, bounded pool diagnostics, concurrency/crash tests and offline matrix/profile | +| `src/db/{posts,threads,schema}.rs` | FTS/page queries, two additive indexes, atomic posting revalidation/bump logic, equivalence tests | +| `src/handlers/{mod,posting,thread}.rs`, `src/handlers/board/create_thread.rs`, `src/handlers/posting/performance.rs` | Scoped posting/media ownership, policy race tests, actual PNG harness | +| `src/handlers/admin/backup/create.rs` | Release live connection after authorized snapshot; package snapshot metadata | +| `src/{pending_fs.rs,media/prune.rs}` | Scoped promotion, replay/startup cleanup and configured pruning while preserving recovery boundaries | +| `src/server/server.rs`, `src/server/server/{observability,assets}.rs` | Blocking recurring DB work, accurate checkpoint/pool metrics; required comma formatting | +| `tests/e2e/audit-nojs-parity.spec.ts` | Verify stored upload format and actual bytes | +| `docs/sqlite-engineering.md`, `docs/sqlite-evidence/*.json` | Audit, measured decisions and compact reproducible raw evidence | + +Reproduce the offline evidence from the current tree (run serially to avoid +benchmark interference): + +```sh +RUSTCHAN_DB_EVIDENCE=/tmp/rustchan-db-matrix.json cargo test --locked --lib db::workload::benchmark -- --ignored --exact +RUSTCHAN_DB_QUERY_EVIDENCE=/tmp/rustchan-db-queries.json cargo test --locked --lib db::workload::query_profile -- --ignored --exact +RUSTCHAN_DB_POST_EVIDENCE=/tmp/rustchan-db-media.json cargo test --locked --lib server::handlers::posting::performance::posting_connection_workload -- --ignored --exact +``` + +The crash child is started and killed by its normal parent regression, not +manually invoked. Generated databases are temporary and are never committed. + +## 13. Remaining limitations + +SQLite WAL still has one writer. Short transactions and scoped connections cannot +remove that limit. Consider PostgreSQL only when measurements show sustained +write-lock p95/p99 approaching the one-second busy bound, persistent retryable busy +errors despite short indexed writes, a growing durable job backlog attributable +to write contention, or a real requirement for multiple application servers to +share transactional state. Filesystem coordination would still need redesign for +multiple servers; replacing SQLite alone does not solve it. + +A real storage power-loss campaign, release-build deployment-scale soak, sampled +peak RSS, diverse filesystems/devices and cross-platform timing are outside the +local evidence. Operators should watch pool wait/hold warnings, debug lock/SQL +labels, job queues and WAL growth under their actual workload. Existing deep +health/repair and exclusive restore work can be expensive by design. Nothing in +these measurements justifies a database migration today. diff --git a/docs/sqlite-evidence/acceptance-matrix.json b/docs/sqlite-evidence/acceptance-matrix.json new file mode 100644 index 00000000..a14d73a3 --- /dev/null +++ b/docs/sqlite-evidence/acceptance-matrix.json @@ -0,0 +1,1875 @@ +{ + "fixture": { + "boards": 8, + "posts": 40000, + "threads": 800 + }, + "limitations": "SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss", + "measurements": [ + { + "busy_errors": 0, + "checkout": { + "max_us": 27149, + "p50_us": 3, + "p95_us": 8033, + "p99_us": 14199 + }, + "checkout_over_100us": 1150, + "checkpoint": [ + 0, + 1233, + 1233 + ], + "checkpoint_us": 3243, + "cpu_seconds": 1.7, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 28069, + "p50_us": 700, + "p95_us": 8665, + "p99_us": 14763 + }, + "operations": 3200, + "operations_per_second": 3650.079952434896, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 27768, + "p50_us": 703, + "p95_us": 8624, + "p99_us": 14763 + }, + "rss_kib": 67952, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8800, + "p50_us": 479, + "p95_us": 1068, + "p99_us": 8203 + }, + "wal_bytes": 5079992, + "workload": "read_heavy", + "write_latency": { + "max_us": 28069, + "p50_us": 593, + "p95_us": 8688, + "p99_us": 19888 + }, + "write_wait": { + "max_us": 1590, + "p50_us": 3, + "p95_us": 1352, + "p99_us": 1579 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 30565, + "p50_us": 2303, + "p95_us": 10982, + "p99_us": 17861 + }, + "checkout_over_100us": 1821, + "checkpoint": [ + 0, + 135, + 135 + ], + "checkpoint_us": 859, + "cpu_seconds": 1.71, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 114153, + "p50_us": 2844, + "p95_us": 12615, + "p99_us": 23983 + }, + "operations": 3200, + "operations_per_second": 1921.250493915227, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 28996, + "p50_us": 2835, + "p95_us": 12281, + "p99_us": 20738 + }, + "rss_kib": 61344, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9068, + "p50_us": 443, + "p95_us": 716, + "p99_us": 4222 + }, + "wal_bytes": 5005832, + "workload": "write_burst", + "write_latency": { + "max_us": 114153, + "p50_us": 2844, + "p95_us": 12639, + "p99_us": 27587 + }, + "write_wait": { + "max_us": 113625, + "p50_us": 3, + "p95_us": 1543, + "p99_us": 26999 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 34851, + "p50_us": 3, + "p95_us": 9006, + "p99_us": 14422 + }, + "checkout_over_100us": 1360, + "checkpoint": [ + 0, + 252, + 252 + ], + "checkpoint_us": 1330, + "cpu_seconds": 1.8099999999999987, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 36365, + "p50_us": 1566, + "p95_us": 9779, + "p99_us": 15366 + }, + "operations": 3200, + "operations_per_second": 2838.8765646702172, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 36365, + "p50_us": 1547, + "p95_us": 10217, + "p99_us": 16035 + }, + "rss_kib": 61040, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9512, + "p50_us": 449, + "p95_us": 882, + "p99_us": 4405 + }, + "wal_bytes": 5615592, + "workload": "mixed", + "write_latency": { + "max_us": 29937, + "p50_us": 1780, + "p95_us": 9298, + "p99_us": 14316 + }, + "write_wait": { + "max_us": 26429, + "p50_us": 3, + "p95_us": 1562, + "p99_us": 4576 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 18458, + "p50_us": 2, + "p95_us": 3844, + "p99_us": 7046 + }, + "checkout_over_100us": 810, + "checkpoint": [ + 0, + 23, + 23 + ], + "checkpoint_us": 445, + "cpu_seconds": 1.9800000000000004, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 26444, + "p50_us": 745, + "p95_us": 4830, + "p99_us": 8756 + }, + "operations": 3200, + "operations_per_second": 5754.384443533797, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 20128, + "p50_us": 735, + "p95_us": 4789, + "p99_us": 8522 + }, + "rss_kib": 80720, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8860, + "p50_us": 771, + "p95_us": 1170, + "p99_us": 7571 + }, + "wal_bytes": 14160472, + "workload": "read_heavy", + "write_latency": { + "max_us": 26444, + "p50_us": 896, + "p95_us": 5532, + "p99_us": 15629 + }, + "write_wait": { + "max_us": 24071, + "p50_us": 3, + "p95_us": 1571, + "p99_us": 12125 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 20149, + "p50_us": 4, + "p95_us": 6542, + "p99_us": 11044 + }, + "checkout_over_100us": 1543, + "checkpoint": [ + 0, + 621, + 621 + ], + "checkpoint_us": 2676, + "cpu_seconds": 1.7300000000000004, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 594165, + "p50_us": 1681, + "p95_us": 9086, + "p99_us": 27270 + }, + "operations": 3200, + "operations_per_second": 1921.7697096814027, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 18091, + "p50_us": 1660, + "p95_us": 7814, + "p99_us": 12071 + }, + "rss_kib": 82736, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8397, + "p50_us": 448, + "p95_us": 815, + "p99_us": 4211 + }, + "wal_bytes": 5347792, + "workload": "write_burst", + "write_latency": { + "max_us": 594165, + "p50_us": 1711, + "p95_us": 10227, + "p99_us": 47309 + }, + "write_wait": { + "max_us": 593277, + "p50_us": 3, + "p95_us": 4409, + "p99_us": 45799 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 18886, + "p50_us": 3, + "p95_us": 5396, + "p99_us": 10221 + }, + "checkout_over_100us": 1158, + "checkpoint": [ + 0, + 83, + 83 + ], + "checkpoint_us": 665, + "cpu_seconds": 1.9399999999999977, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 135716, + "p50_us": 884, + "p95_us": 7455, + "p99_us": 14588 + }, + "operations": 3200, + "operations_per_second": 3262.3293833718617, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 19109, + "p50_us": 824, + "p95_us": 6182, + "p99_us": 11736 + }, + "rss_kib": 78080, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8490, + "p50_us": 489, + "p95_us": 1243, + "p99_us": 3388 + }, + "wal_bytes": 8149392, + "workload": "mixed", + "write_latency": { + "max_us": 135716, + "p50_us": 1809, + "p95_us": 12097, + "p99_us": 47023 + }, + "write_wait": { + "max_us": 135088, + "p50_us": 4, + "p95_us": 10518, + "p99_us": 46562 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 154, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 1825, + 1825 + ], + "checkpoint_us": 8, + "cpu_seconds": 3.460000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 116031, + "p50_us": 829, + "p95_us": 3832, + "p99_us": 6279 + }, + "operations": 3200, + "operations_per_second": 5381.314107521391, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11224, + "p50_us": 816, + "p95_us": 3654, + "p99_us": 4877 + }, + "rss_kib": 92352, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10085, + "p50_us": 1530, + "p95_us": 3494, + "p99_us": 8633 + }, + "wal_bytes": 7519032, + "workload": "read_heavy", + "write_latency": { + "max_us": 116031, + "p50_us": 2102, + "p95_us": 12638, + "p99_us": 107337 + }, + "write_wait": { + "max_us": 114254, + "p50_us": 6, + "p95_us": 11564, + "p99_us": 105421 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 71, + "p50_us": 2, + "p95_us": 4, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 132, + 132 + ], + "checkpoint_us": 709, + "cpu_seconds": 1.8200000000000003, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 1035134, + "p50_us": 463, + "p95_us": 5041, + "p99_us": 41292 + }, + "operations": 3200, + "operations_per_second": 1849.7705923182207, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 6219, + "p50_us": 440, + "p95_us": 1809, + "p99_us": 2978 + }, + "rss_kib": 116144, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9817, + "p50_us": 456, + "p95_us": 1079, + "p99_us": 4089 + }, + "wal_bytes": 5409592, + "workload": "write_burst", + "write_latency": { + "max_us": 1035134, + "p50_us": 465, + "p95_us": 5486, + "p99_us": 77682 + }, + "write_wait": { + "max_us": 1033848, + "p50_us": 3, + "p95_us": 4591, + "p99_us": 76899 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 67, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 575, + 575 + ], + "checkpoint_us": 2817, + "cpu_seconds": 1.9699999999999989, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 598539, + "p50_us": 542, + "p95_us": 3643, + "p99_us": 24197 + }, + "operations": 3200, + "operations_per_second": 3314.158252351143, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 10182, + "p50_us": 425, + "p95_us": 1755, + "p99_us": 2129 + }, + "rss_kib": 94912, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8632, + "p50_us": 520, + "p95_us": 1481, + "p99_us": 3293 + }, + "wal_bytes": 12751432, + "workload": "mixed", + "write_latency": { + "max_us": 598539, + "p50_us": 742, + "p95_us": 12847, + "p99_us": 104105 + }, + "write_wait": { + "max_us": 597956, + "p50_us": 41, + "p95_us": 12098, + "p99_us": 103340 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 69, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 851, + 851 + ], + "checkpoint_us": 5235, + "cpu_seconds": 3.6000000000000085, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 109325, + "p50_us": 855, + "p95_us": 3783, + "p99_us": 6434 + }, + "operations": 3200, + "operations_per_second": 5242.628932530368, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11163, + "p50_us": 833, + "p95_us": 3662, + "p99_us": 4656 + }, + "rss_kib": 86688, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12806, + "p50_us": 1662, + "p95_us": 3054, + "p99_us": 10754 + }, + "wal_bytes": 10749112, + "workload": "read_heavy", + "write_latency": { + "max_us": 109325, + "p50_us": 2238, + "p95_us": 13754, + "p99_us": 29293 + }, + "write_wait": { + "max_us": 107440, + "p50_us": 32, + "p95_us": 12144, + "p99_us": 27222 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 216, + 216 + ], + "checkpoint_us": 909, + "cpu_seconds": 1.6999999999999886, + "database_bytes": 15192064, + "failures": 0, + "latency": { + "max_us": 809888, + "p50_us": 445, + "p95_us": 4936, + "p99_us": 72383 + }, + "operations": 3200, + "operations_per_second": 1970.4619041277326, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 9164, + "p50_us": 425, + "p95_us": 1713, + "p99_us": 1988 + }, + "rss_kib": 93184, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8564, + "p50_us": 437, + "p95_us": 779, + "p99_us": 4459 + }, + "wal_bytes": 5759792, + "workload": "write_burst", + "write_latency": { + "max_us": 809888, + "p50_us": 447, + "p95_us": 5273, + "p99_us": 105280 + }, + "write_wait": { + "max_us": 809258, + "p50_us": 3, + "p95_us": 4582, + "p99_us": 104525 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 67, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 68, + 68 + ], + "checkpoint_us": 583, + "cpu_seconds": 2.0, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 578485, + "p50_us": 553, + "p95_us": 5020, + "p99_us": 27582 + }, + "operations": 3200, + "operations_per_second": 3386.712867074695, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12570, + "p50_us": 427, + "p95_us": 1766, + "p99_us": 2181 + }, + "rss_kib": 89952, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8781, + "p50_us": 517, + "p95_us": 1339, + "p99_us": 3472 + }, + "wal_bytes": 15800232, + "workload": "mixed", + "write_latency": { + "max_us": 578485, + "p50_us": 800, + "p95_us": 25266, + "p99_us": 72704 + }, + "write_wait": { + "max_us": 577857, + "p50_us": 46, + "p95_us": 24682, + "p99_us": 72225 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 62, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 83, + 83 + ], + "checkpoint_us": 1144, + "cpu_seconds": 3.530000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 80311, + "p50_us": 850, + "p95_us": 3665, + "p99_us": 6606 + }, + "operations": 3200, + "operations_per_second": 5309.300633585225, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12853, + "p50_us": 820, + "p95_us": 3526, + "p99_us": 4442 + }, + "rss_kib": 86416, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10221, + "p50_us": 1739, + "p95_us": 2915, + "p99_us": 9542 + }, + "wal_bytes": 13905032, + "workload": "read_heavy", + "write_latency": { + "max_us": 80311, + "p50_us": 2306, + "p95_us": 13655, + "p99_us": 78021 + }, + "write_wait": { + "max_us": 79736, + "p50_us": 71, + "p95_us": 12077, + "p99_us": 75820 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 71, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 645, + 645 + ], + "checkpoint_us": 2550, + "cpu_seconds": 1.6899999999999977, + "database_bytes": 15192064, + "failures": 0, + "latency": { + "max_us": 813625, + "p50_us": 448, + "p95_us": 3475, + "p99_us": 46437 + }, + "operations": 3200, + "operations_per_second": 1904.5372521228326, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 6073, + "p50_us": 420, + "p95_us": 1699, + "p99_us": 2019 + }, + "rss_kib": 124976, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8339, + "p50_us": 443, + "p95_us": 702, + "p99_us": 4531 + }, + "wal_bytes": 5417832, + "workload": "write_burst", + "write_latency": { + "max_us": 813625, + "p50_us": 450, + "p95_us": 5016, + "p99_us": 74327 + }, + "write_wait": { + "max_us": 811513, + "p50_us": 3, + "p95_us": 3878, + "p99_us": 73730 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 69, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 303, + 303 + ], + "checkpoint_us": 1721, + "cpu_seconds": 2.0200000000000102, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 238312, + "p50_us": 600, + "p95_us": 4847, + "p99_us": 25798 + }, + "operations": 3200, + "operations_per_second": 3560.797512397481, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12025, + "p50_us": 439, + "p95_us": 1783, + "p99_us": 2033 + }, + "rss_kib": 91248, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8660, + "p50_us": 555, + "p95_us": 1441, + "p99_us": 3546 + }, + "wal_bytes": 11358872, + "workload": "mixed", + "write_latency": { + "max_us": 238312, + "p50_us": 784, + "p95_us": 24805, + "p99_us": 105116 + }, + "write_wait": { + "max_us": 237500, + "p50_us": 47, + "p95_us": 24056, + "p99_us": 104677 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 33116, + "p50_us": 3, + "p95_us": 7830, + "p99_us": 15332 + }, + "checkout_over_100us": 1168, + "checkpoint": [ + 0, + 800, + 800 + ], + "checkpoint_us": 3138, + "cpu_seconds": 1.6899999999999977, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 33458, + "p50_us": 704, + "p95_us": 8448, + "p99_us": 16080 + }, + "operations": 3200, + "operations_per_second": 3631.23366052655, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 33458, + "p50_us": 703, + "p95_us": 8341, + "p99_us": 15680 + }, + "rss_kib": 74752, + "synchronous": "FULL", + "transaction": { + "max_us": 9017, + "p50_us": 528, + "p95_us": 1526, + "p99_us": 8720 + }, + "wal_bytes": 5953432, + "workload": "read_heavy", + "write_latency": { + "max_us": 26528, + "p50_us": 719, + "p95_us": 10417, + "p99_us": 23649 + }, + "write_wait": { + "max_us": 10059, + "p50_us": 3, + "p95_us": 217, + "p99_us": 4576 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 31893, + "p50_us": 2472, + "p95_us": 10840, + "p99_us": 16138 + }, + "checkout_over_100us": 1894, + "checkpoint": [ + 0, + 638, + 638 + ], + "checkpoint_us": 1654, + "cpu_seconds": 1.6899999999999977, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 302950, + "p50_us": 3017, + "p95_us": 12289, + "p99_us": 21848 + }, + "operations": 3200, + "operations_per_second": 1839.2328930668255, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 24760, + "p50_us": 3195, + "p95_us": 11544, + "p99_us": 16356 + }, + "rss_kib": 77824, + "synchronous": "FULL", + "transaction": { + "max_us": 8674, + "p50_us": 472, + "p95_us": 683, + "p99_us": 3385 + }, + "wal_bytes": 4911072, + "workload": "write_burst", + "write_latency": { + "max_us": 302950, + "p50_us": 2921, + "p95_us": 12703, + "p99_us": 27447 + }, + "write_wait": { + "max_us": 300226, + "p50_us": 3, + "p95_us": 4, + "p99_us": 23726 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 29149, + "p50_us": 3, + "p95_us": 8763, + "p99_us": 15037 + }, + "checkout_over_100us": 1336, + "checkpoint": [ + 0, + 20, + 20 + ], + "checkpoint_us": 329, + "cpu_seconds": 1.789999999999992, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 50419, + "p50_us": 1452, + "p95_us": 9742, + "p99_us": 16103 + }, + "operations": 3200, + "operations_per_second": 2843.686024534678, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 29228, + "p50_us": 1365, + "p95_us": 9572, + "p99_us": 15613 + }, + "rss_kib": 76896, + "synchronous": "FULL", + "transaction": { + "max_us": 8357, + "p50_us": 481, + "p95_us": 902, + "p99_us": 3327 + }, + "wal_bytes": 5207712, + "workload": "mixed", + "write_latency": { + "max_us": 50419, + "p50_us": 1798, + "p95_us": 10274, + "p99_us": 17542 + }, + "write_wait": { + "max_us": 49616, + "p50_us": 3, + "p95_us": 1560, + "p99_us": 4563 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 26552, + "p50_us": 3, + "p95_us": 3869, + "p99_us": 7489 + }, + "checkout_over_100us": 835, + "checkpoint": [ + 0, + 23, + 23 + ], + "checkpoint_us": 509, + "cpu_seconds": 1.990000000000009, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 26923, + "p50_us": 747, + "p95_us": 4726, + "p99_us": 9305 + }, + "operations": 3200, + "operations_per_second": 5722.362594061626, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 26923, + "p50_us": 742, + "p95_us": 4567, + "p99_us": 8710 + }, + "rss_kib": 77280, + "synchronous": "FULL", + "transaction": { + "max_us": 9183, + "p50_us": 661, + "p95_us": 1383, + "p99_us": 6254 + }, + "wal_bytes": 8837432, + "workload": "read_heavy", + "write_latency": { + "max_us": 13458, + "p50_us": 863, + "p95_us": 7108, + "p99_us": 12686 + }, + "write_wait": { + "max_us": 12105, + "p50_us": 3, + "p95_us": 1662, + "p99_us": 12104 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 23500, + "p50_us": 598, + "p95_us": 6287, + "p99_us": 10059 + }, + "checkout_over_100us": 1643, + "checkpoint": [ + 0, + 461, + 461 + ], + "checkpoint_us": 1461, + "cpu_seconds": 1.740000000000009, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 588000, + "p50_us": 1710, + "p95_us": 8511, + "p99_us": 26381 + }, + "operations": 3200, + "operations_per_second": 1828.9674439182554, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 18283, + "p50_us": 1662, + "p95_us": 7368, + "p99_us": 12336 + }, + "rss_kib": 90080, + "synchronous": "FULL", + "transaction": { + "max_us": 8819, + "p50_us": 480, + "p95_us": 724, + "p99_us": 3364 + }, + "wal_bytes": 5121192, + "workload": "write_burst", + "write_latency": { + "max_us": 588000, + "p50_us": 1789, + "p95_us": 9141, + "p99_us": 46981 + }, + "write_wait": { + "max_us": 587067, + "p50_us": 3, + "p95_us": 1574, + "p99_us": 45571 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 20907, + "p50_us": 3, + "p95_us": 5376, + "p99_us": 8985 + }, + "checkout_over_100us": 1135, + "checkpoint": [ + 0, + 236, + 236 + ], + "checkpoint_us": 1139, + "cpu_seconds": 1.9199999999999875, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 130241, + "p50_us": 839, + "p95_us": 7078, + "p99_us": 12853 + }, + "operations": 3200, + "operations_per_second": 3232.2987452115262, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 21064, + "p50_us": 794, + "p95_us": 6132, + "p99_us": 9988 + }, + "rss_kib": 81168, + "synchronous": "FULL", + "transaction": { + "max_us": 8776, + "p50_us": 518, + "p95_us": 1137, + "p99_us": 3198 + }, + "wal_bytes": 7576712, + "workload": "mixed", + "write_latency": { + "max_us": 130241, + "p50_us": 1904, + "p95_us": 11741, + "p99_us": 47657 + }, + "write_wait": { + "max_us": 128660, + "p50_us": 4, + "p95_us": 9723, + "p99_us": 47130 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 35, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 43, + 43 + ], + "checkpoint_us": 498, + "cpu_seconds": 3.5, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 111647, + "p50_us": 840, + "p95_us": 3618, + "p99_us": 6471 + }, + "operations": 3200, + "operations_per_second": 5501.155242600947, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11511, + "p50_us": 821, + "p95_us": 3438, + "p99_us": 4272 + }, + "rss_kib": 91472, + "synchronous": "FULL", + "transaction": { + "max_us": 11056, + "p50_us": 1729, + "p95_us": 3051, + "p99_us": 8137 + }, + "wal_bytes": 14078072, + "workload": "read_heavy", + "write_latency": { + "max_us": 111647, + "p50_us": 2501, + "p95_us": 12846, + "p99_us": 50634 + }, + "write_wait": { + "max_us": 109849, + "p50_us": 96, + "p95_us": 11995, + "p99_us": 49590 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 88, + "p50_us": 3, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 763, + 763 + ], + "checkpoint_us": 2699, + "cpu_seconds": 1.700000000000017, + "database_bytes": 15196160, + "failures": 0, + "latency": { + "max_us": 1034435, + "p50_us": 482, + "p95_us": 3080, + "p99_us": 67702 + }, + "operations": 3200, + "operations_per_second": 1746.521431569672, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 10375, + "p50_us": 423, + "p95_us": 1680, + "p99_us": 1964 + }, + "rss_kib": 115024, + "synchronous": "FULL", + "transaction": { + "max_us": 8145, + "p50_us": 477, + "p95_us": 757, + "p99_us": 3321 + }, + "wal_bytes": 4898712, + "workload": "write_burst", + "write_latency": { + "max_us": 1034435, + "p50_us": 488, + "p95_us": 5043, + "p99_us": 108172 + }, + "write_wait": { + "max_us": 1033877, + "p50_us": 3, + "p95_us": 4551, + "p99_us": 107027 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 71, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 695, + 695 + ], + "checkpoint_us": 2054, + "cpu_seconds": 1.9199999999999875, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 793298, + "p50_us": 517, + "p95_us": 2624, + "p99_us": 27526 + }, + "operations": 3200, + "operations_per_second": 3065.4335960026747, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11631, + "p50_us": 419, + "p95_us": 1735, + "p99_us": 1969 + }, + "rss_kib": 79952, + "synchronous": "FULL", + "transaction": { + "max_us": 8951, + "p50_us": 523, + "p95_us": 1169, + "p99_us": 3226 + }, + "wal_bytes": 7650872, + "workload": "mixed", + "write_latency": { + "max_us": 793298, + "p50_us": 634, + "p95_us": 20784, + "p99_us": 95912 + }, + "write_wait": { + "max_us": 792386, + "p50_us": 35, + "p95_us": 20170, + "p99_us": 95366 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 46, + 46 + ], + "checkpoint_us": 548, + "cpu_seconds": 3.2700000000000102, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 74887, + "p50_us": 821, + "p95_us": 3441, + "p99_us": 6710 + }, + "operations": 3200, + "operations_per_second": 5622.350662714731, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12940, + "p50_us": 803, + "p95_us": 3284, + "p99_us": 3969 + }, + "rss_kib": 107440, + "synchronous": "FULL", + "transaction": { + "max_us": 11576, + "p50_us": 1502, + "p95_us": 3124, + "p99_us": 6360 + }, + "wal_bytes": 14065712, + "workload": "read_heavy", + "write_latency": { + "max_us": 74887, + "p50_us": 2312, + "p95_us": 25952, + "p99_us": 51970 + }, + "write_wait": { + "max_us": 73849, + "p50_us": 90, + "p95_us": 24522, + "p99_us": 49783 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 39, + "p50_us": 3, + "p95_us": 4, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 251, + 251 + ], + "checkpoint_us": 953, + "cpu_seconds": 1.710000000000008, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 709856, + "p50_us": 483, + "p95_us": 3158, + "p99_us": 66890 + }, + "operations": 3200, + "operations_per_second": 1872.531239453297, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 9407, + "p50_us": 428, + "p95_us": 1715, + "p99_us": 1940 + }, + "rss_kib": 126016, + "synchronous": "FULL", + "transaction": { + "max_us": 9196, + "p50_us": 477, + "p95_us": 717, + "p99_us": 3197 + }, + "wal_bytes": 5191232, + "workload": "write_burst", + "write_latency": { + "max_us": 709856, + "p50_us": 486, + "p95_us": 5071, + "p99_us": 106574 + }, + "write_wait": { + "max_us": 709214, + "p50_us": 3, + "p95_us": 4558, + "p99_us": 105965 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 265, + "p50_us": 3, + "p95_us": 5, + "p99_us": 10 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 950, + 950 + ], + "checkpoint_us": 2305, + "cpu_seconds": 2.1899999999999977, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 369313, + "p50_us": 654, + "p95_us": 5109, + "p99_us": 37743 + }, + "operations": 3200, + "operations_per_second": 2977.345654182638, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 21597, + "p50_us": 464, + "p95_us": 1920, + "p99_us": 3395 + }, + "rss_kib": 83600, + "synchronous": "FULL", + "transaction": { + "max_us": 11496, + "p50_us": 606, + "p95_us": 1799, + "p99_us": 3621 + }, + "wal_bytes": 10032232, + "workload": "mixed", + "write_latency": { + "max_us": 369313, + "p50_us": 1221, + "p95_us": 25719, + "p99_us": 103672 + }, + "write_wait": { + "max_us": 368612, + "p50_us": 70, + "p95_us": 25212, + "p99_us": 103009 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 61, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 23, + 23 + ], + "checkpoint_us": 455, + "cpu_seconds": 3.530000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 117519, + "p50_us": 848, + "p95_us": 3684, + "p99_us": 6660 + }, + "operations": 3200, + "operations_per_second": 5406.892985971436, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 13140, + "p50_us": 827, + "p95_us": 3554, + "p99_us": 4742 + }, + "rss_kib": 99040, + "synchronous": "FULL", + "transaction": { + "max_us": 11477, + "p50_us": 1526, + "p95_us": 3124, + "p99_us": 6922 + }, + "wal_bytes": 7774472, + "workload": "read_heavy", + "write_latency": { + "max_us": 117519, + "p50_us": 2229, + "p95_us": 12974, + "p99_us": 49962 + }, + "write_wait": { + "max_us": 115170, + "p50_us": 18, + "p95_us": 12105, + "p99_us": 48442 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 65, + "p50_us": 3, + "p95_us": 4, + "p99_us": 7 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 931, + 931 + ], + "checkpoint_us": 2657, + "cpu_seconds": 1.7299999999999898, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 1030277, + "p50_us": 490, + "p95_us": 3848, + "p99_us": 45559 + }, + "operations": 3200, + "operations_per_second": 1869.068763625066, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 16892, + "p50_us": 430, + "p95_us": 1729, + "p99_us": 2254 + }, + "rss_kib": 110080, + "synchronous": "FULL", + "transaction": { + "max_us": 11224, + "p50_us": 483, + "p95_us": 744, + "p99_us": 3316 + }, + "wal_bytes": 4886352, + "workload": "write_burst", + "write_latency": { + "max_us": 1030277, + "p50_us": 494, + "p95_us": 5134, + "p99_us": 75572 + }, + "write_wait": { + "max_us": 1029713, + "p50_us": 3, + "p95_us": 4561, + "p99_us": 74938 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 62, + "p50_us": 2, + "p95_us": 4, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 631, + 631 + ], + "checkpoint_us": 2772, + "cpu_seconds": 2.0500000000000114, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 588826, + "p50_us": 592, + "p95_us": 4978, + "p99_us": 27463 + }, + "operations": 3200, + "operations_per_second": 3215.504358138856, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11548, + "p50_us": 441, + "p95_us": 1814, + "p99_us": 2105 + }, + "rss_kib": 88416, + "synchronous": "FULL", + "transaction": { + "max_us": 9129, + "p50_us": 575, + "p95_us": 1242, + "p99_us": 3697 + }, + "wal_bytes": 11074592, + "workload": "mixed", + "write_latency": { + "max_us": 588826, + "p50_us": 749, + "p95_us": 25554, + "p99_us": 109289 + }, + "write_wait": { + "max_us": 588273, + "p50_us": 46, + "p95_us": 23781, + "p99_us": 107634 + } + } + ], + "query_plans": [ + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "board_index" + }, + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "catalog" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "thread_loading" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "name": "latest_post" + }, + { + "details": [ + "SEARCH threads USING INTEGER PRIMARY KEY (rowid=?)", + "SCALAR SUBQUERY 1", + "SEARCH boards USING INTEGER PRIMARY KEY (rowid=?)" + ], + "name": "thread_bump" + }, + { + "details": [ + "SCAN CONSTANT ROW", + "SCALAR SUBQUERY 1", + "SCAN posts USING COVERING INDEX idx_posts_board", + "SCALAR SUBQUERY 2", + "SCAN threads USING COVERING INDEX idx_threads_archived", + "SCALAR SUBQUERY 3", + "SEARCH reports USING COVERING INDEX idx_reports_status (status=?)" + ], + "name": "admin_overview" + }, + { + "details": [ + "SEARCH reports USING COVERING INDEX idx_reports_open_unique (post_id=? AND reporter_hash=?)" + ], + "name": "report_lookup" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "backup_board_inventory" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "name": "live_poll" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "name": "cooldown" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "latest_thread" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)" + ], + "name": "prune" + }, + { + "details": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "search_selective" + }, + { + "details": [ + "SEARCH reports USING INDEX idx_reports_status (status=?)" + ], + "name": "reports" + }, + { + "details": [ + "SEARCH bans USING INDEX idx_bans_ip (ip_hash=?)" + ], + "name": "bans" + }, + { + "details": [ + "SEARCH background_jobs USING INDEX idx_jobs_pending (status=?)" + ], + "name": "job_claim" + }, + { + "details": [ + "MULTI-INDEX OR", + "INDEX 1", + "SEARCH posts USING INDEX idx_posts_file_path (file_path=?)", + "INDEX 2", + "SEARCH posts USING INDEX idx_posts_thumb_path (thumb_path=?)", + "INDEX 3", + "SEARCH posts USING INDEX idx_posts_audio_file_path (audio_file_path=?)" + ], + "name": "file_refs" + }, + { + "details": [ + "SEARCH file_hashes USING INDEX sqlite_autoindex_file_hashes_1 (sha256=?)" + ], + "name": "dedup" + }, + { + "details": [ + "SCAN mod_log USING INDEX idx_mod_log_created" + ], + "name": "mod_log" + } + ], + "sqlite_version": "3.51.3", + "workers": 8 +} \ No newline at end of file diff --git a/docs/sqlite-evidence/baseline.json b/docs/sqlite-evidence/baseline.json new file mode 100644 index 00000000..adca7f12 --- /dev/null +++ b/docs/sqlite-evidence/baseline.json @@ -0,0 +1,1391 @@ +{ + "fixture": { + "boards": 8, + "posts": 40000, + "threads": 800 + }, + "limitations": "SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss", + "measurements": [ + { + "busy_errors": 0, + "checkout": { + "max_us": 464568, + "p50_us": 20250, + "p95_us": 179150, + "p99_us": 273655 + }, + "checkout_over_100us": 2353, + "checkpoint": [ + 0, + 356, + 356 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 533293, + "p50_us": 27230, + "p95_us": 234122, + "p99_us": 327467 + }, + "operations": 3200, + "operations_per_second": 108.92363673420175, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 64608, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9411, + "p50_us": 688, + "p95_us": 1318, + "p99_us": 2937 + }, + "wal_bytes": 11284712, + "workload": "read_heavy", + "write_wait": { + "max_us": 1548, + "p50_us": 7, + "p95_us": 52, + "p99_us": 127 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 358504, + "p50_us": 5235, + "p95_us": 108697, + "p99_us": 157718 + }, + "checkout_over_100us": 2152, + "checkpoint": [ + 0, + 101, + 101 + ], + "database_bytes": 13463552, + "failures": 0, + "latency": { + "max_us": 383902, + "p50_us": 7464, + "p95_us": 128914, + "p99_us": 190777 + }, + "operations": 3200, + "operations_per_second": 289.70630166500194, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 57216, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8978, + "p50_us": 497, + "p95_us": 1047, + "p99_us": 2039 + }, + "wal_bytes": 21580592, + "workload": "write_burst", + "write_wait": { + "max_us": 12108, + "p50_us": 4, + "p95_us": 58, + "p99_us": 1648 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 490567, + "p50_us": 15643, + "p95_us": 170101, + "p99_us": 255079 + }, + "checkout_over_100us": 2302, + "checkpoint": [ + 0, + 93, + 93 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 603467, + "p50_us": 21002, + "p95_us": 212974, + "p99_us": 320300 + }, + "operations": 3200, + "operations_per_second": 126.22044860704878, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 61888, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9027, + "p50_us": 529, + "p95_us": 1262, + "p99_us": 2709 + }, + "wal_bytes": 26289752, + "workload": "mixed", + "write_wait": { + "max_us": 1587, + "p50_us": 4, + "p95_us": 67, + "p99_us": 240 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 305115, + "p50_us": 13008, + "p95_us": 114621, + "p99_us": 173946 + }, + "checkout_over_100us": 2312, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 518826, + "p50_us": 20684, + "p95_us": 226231, + "p99_us": 345693 + }, + "operations": 3200, + "operations_per_second": 140.50231131127484, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 82528, + "synchronous": "NORMAL", + "transaction": { + "max_us": 11778, + "p50_us": 999, + "p95_us": 1707, + "p99_us": 5164 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 160, + "p50_us": 7, + "p95_us": 123, + "p99_us": 158 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 145753, + "p50_us": 3469, + "p95_us": 39908, + "p99_us": 97550 + }, + "checkout_over_100us": 2465, + "checkpoint": [ + 0, + 100, + 100 + ], + "database_bytes": 13463552, + "failures": 0, + "latency": { + "max_us": 401769, + "p50_us": 5806, + "p95_us": 103074, + "p99_us": 223400 + }, + "operations": 3200, + "operations_per_second": 401.0883281103068, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 66064, + "synchronous": "NORMAL", + "transaction": { + "max_us": 11700, + "p50_us": 725, + "p95_us": 1199, + "p99_us": 2145 + }, + "wal_bytes": 173175992, + "workload": "write_burst", + "write_wait": { + "max_us": 49625, + "p50_us": 4, + "p95_us": 1553, + "p99_us": 4653 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 268401, + "p50_us": 11289, + "p95_us": 101325, + "p99_us": 157297 + }, + "checkout_over_100us": 2413, + "checkpoint": [ + 0, + 14986, + 14986 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 467960, + "p50_us": 15643, + "p95_us": 215325, + "p99_us": 300229 + }, + "operations": 3200, + "operations_per_second": 173.29291267119606, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 74944, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10135, + "p50_us": 814, + "p95_us": 1715, + "p99_us": 3041 + }, + "wal_bytes": 61742352, + "workload": "mixed", + "write_wait": { + "max_us": 4649, + "p50_us": 5, + "p95_us": 125, + "p99_us": 1600 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 67, + "p50_us": 4, + "p95_us": 11, + "p99_us": 16 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1052826, + "p50_us": 3915, + "p95_us": 694506, + "p99_us": 950879 + }, + "operations": 3200, + "operations_per_second": 77.5297546295621, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 105280, + "synchronous": "NORMAL", + "transaction": { + "max_us": 20400, + "p50_us": 2527, + "p95_us": 4900, + "p99_us": 6397 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 1545, + "p50_us": 9, + "p95_us": 189, + "p99_us": 260 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 4, + "p95_us": 5, + "p99_us": 10 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 41, + 41 + ], + "database_bytes": 13463552, + "failures": 0, + "latency": { + "max_us": 1175750, + "p50_us": 2070, + "p95_us": 57696, + "p99_us": 807621 + }, + "operations": 3200, + "operations_per_second": 224.14675037701357, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 92640, + "synchronous": "NORMAL", + "transaction": { + "max_us": 20041, + "p50_us": 1985, + "p95_us": 3199, + "p99_us": 4253 + }, + "wal_bytes": 173876392, + "workload": "write_burst", + "write_wait": { + "max_us": 138331, + "p50_us": 3, + "p95_us": 4654, + "p99_us": 42027 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 90, + "p50_us": 4, + "p95_us": 14, + "p99_us": 20 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 15041, + 15041 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1181026, + "p50_us": 2913, + "p95_us": 654620, + "p99_us": 934125 + }, + "operations": 3200, + "operations_per_second": 95.83439756109111, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 99584, + "synchronous": "NORMAL", + "transaction": { + "max_us": 18247, + "p50_us": 2372, + "p95_us": 3706, + "p99_us": 4907 + }, + "wal_bytes": 61968952, + "workload": "mixed", + "write_wait": { + "max_us": 27216, + "p50_us": 8, + "p95_us": 1567, + "p99_us": 4654 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 192, + "p50_us": 5, + "p95_us": 16, + "p99_us": 21 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1229563, + "p50_us": 4023, + "p95_us": 720485, + "p99_us": 986203 + }, + "operations": 3200, + "operations_per_second": 73.46966631140599, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 76368, + "synchronous": "NORMAL", + "transaction": { + "max_us": 20272, + "p50_us": 2717, + "p95_us": 4930, + "p99_us": 10450 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 273, + "p50_us": 9, + "p95_us": 186, + "p99_us": 222 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 128, + "p50_us": 4, + "p95_us": 10, + "p99_us": 22 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 98, + 98 + ], + "database_bytes": 13467648, + "failures": 0, + "latency": { + "max_us": 1364444, + "p50_us": 2219, + "p95_us": 69133, + "p99_us": 858600 + }, + "operations": 3200, + "operations_per_second": 207.28200018893833, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 63248, + "synchronous": "NORMAL", + "transaction": { + "max_us": 22997, + "p50_us": 2080, + "p95_us": 3572, + "p99_us": 4650 + }, + "wal_bytes": 173612712, + "workload": "write_burst", + "write_wait": { + "max_us": 149846, + "p50_us": 4, + "p95_us": 4696, + "p99_us": 47282 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 103, + "p50_us": 5, + "p95_us": 19, + "p99_us": 28 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 14989, + 14989 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1437463, + "p50_us": 3401, + "p95_us": 724692, + "p99_us": 1057465 + }, + "operations": 3200, + "operations_per_second": 84.05966668169796, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 74480, + "synchronous": "NORMAL", + "transaction": { + "max_us": 23697, + "p50_us": 2793, + "p95_us": 4387, + "p99_us": 5826 + }, + "wal_bytes": 61754712, + "workload": "mixed", + "write_wait": { + "max_us": 12174, + "p50_us": 11, + "p95_us": 1669, + "p99_us": 4675 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 234, + "p50_us": 5, + "p95_us": 20, + "p99_us": 36 + }, + "checkout_over_100us": 3, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1377211, + "p50_us": 4090, + "p95_us": 801741, + "p99_us": 1081945 + }, + "operations": 3200, + "operations_per_second": 67.8800452190142, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 79872, + "synchronous": "NORMAL", + "transaction": { + "max_us": 21835, + "p50_us": 2930, + "p95_us": 5302, + "p99_us": 9048 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 4661, + "p50_us": 10, + "p95_us": 260, + "p99_us": 4613 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 54, + "p50_us": 4, + "p95_us": 6, + "p99_us": 14 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 49, + 49 + ], + "database_bytes": 13467648, + "failures": 0, + "latency": { + "max_us": 1314361, + "p50_us": 2229, + "p95_us": 64952, + "p99_us": 914552 + }, + "operations": 3200, + "operations_per_second": 199.9639044281275, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 73744, + "synchronous": "NORMAL", + "transaction": { + "max_us": 19137, + "p50_us": 2118, + "p95_us": 3536, + "p99_us": 4667 + }, + "wal_bytes": 173843432, + "workload": "write_burst", + "write_wait": { + "max_us": 243128, + "p50_us": 4, + "p95_us": 4669, + "p99_us": 43114 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 52, + "p50_us": 4, + "p95_us": 14, + "p99_us": 22 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 14978, + 14978 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1269234, + "p50_us": 3029, + "p95_us": 685749, + "p99_us": 986730 + }, + "operations": 3200, + "operations_per_second": 91.3617257500745, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 81824, + "synchronous": "NORMAL", + "transaction": { + "max_us": 19824, + "p50_us": 2442, + "p95_us": 3894, + "p99_us": 6056 + }, + "wal_bytes": 61709392, + "workload": "mixed", + "write_wait": { + "max_us": 12117, + "p50_us": 9, + "p95_us": 1629, + "p99_us": 4678 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 508625, + "p50_us": 25477, + "p95_us": 215950, + "p99_us": 295290 + }, + "checkout_over_100us": 2443, + "checkpoint": [ + 0, + 252, + 252 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 542751, + "p50_us": 38687, + "p95_us": 265163, + "p99_us": 389872 + }, + "operations": 3200, + "operations_per_second": 92.46357277579011, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 40864, + "synchronous": "FULL", + "transaction": { + "max_us": 11813, + "p50_us": 968, + "p95_us": 2099, + "p99_us": 9404 + }, + "wal_bytes": 11713192, + "workload": "read_heavy", + "write_wait": { + "max_us": 1677, + "p50_us": 10, + "p95_us": 84, + "p99_us": 150 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 330775, + "p50_us": 7439, + "p95_us": 120112, + "p99_us": 182594 + }, + "checkout_over_100us": 2454, + "checkpoint": [ + 0, + 674, + 674 + ], + "database_bytes": 13463552, + "failures": 0, + "latency": { + "max_us": 394040, + "p50_us": 10798, + "p95_us": 145266, + "p99_us": 216467 + }, + "operations": 3200, + "operations_per_second": 252.29865094585196, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 39648, + "synchronous": "FULL", + "transaction": { + "max_us": 14715, + "p50_us": 666, + "p95_us": 1281, + "p99_us": 2668 + }, + "wal_bytes": 25193832, + "workload": "write_burst", + "write_wait": { + "max_us": 45203, + "p50_us": 5, + "p95_us": 117, + "p99_us": 4530 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 384774, + "p50_us": 18025, + "p95_us": 178709, + "p99_us": 255125 + }, + "checkout_over_100us": 2461, + "checkpoint": [ + 0, + 13298, + 13298 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 537567, + "p50_us": 24322, + "p95_us": 227560, + "p99_us": 313408 + }, + "operations": 3200, + "operations_per_second": 118.5801919314107, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 42368, + "synchronous": "FULL", + "transaction": { + "max_us": 9234, + "p50_us": 747, + "p95_us": 1292, + "p99_us": 3361 + }, + "wal_bytes": 54787792, + "workload": "mixed", + "write_wait": { + "max_us": 4590, + "p50_us": 6, + "p95_us": 97, + "p99_us": 1557 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 309896, + "p50_us": 14882, + "p95_us": 119106, + "p99_us": 188842 + }, + "checkout_over_100us": 2347, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 604633, + "p50_us": 22669, + "p95_us": 244321, + "p99_us": 365405 + }, + "operations": 3200, + "operations_per_second": 130.8936484352161, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 56096, + "synchronous": "FULL", + "transaction": { + "max_us": 10860, + "p50_us": 1147, + "p95_us": 1999, + "p99_us": 5769 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 1627, + "p50_us": 6, + "p95_us": 134, + "p99_us": 204 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 303586, + "p50_us": 4204, + "p95_us": 45842, + "p99_us": 147391 + }, + "checkout_over_100us": 2579, + "checkpoint": [ + 0, + 238, + 238 + ], + "database_bytes": 13467648, + "failures": 0, + "latency": { + "max_us": 466526, + "p50_us": 6582, + "p95_us": 148317, + "p99_us": 297394 + }, + "operations": 3200, + "operations_per_second": 321.4653192916612, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 53888, + "synchronous": "FULL", + "transaction": { + "max_us": 49154, + "p50_us": 843, + "p95_us": 1449, + "p99_us": 2008 + }, + "wal_bytes": 172261352, + "workload": "write_burst", + "write_wait": { + "max_us": 95095, + "p50_us": 5, + "p95_us": 152, + "p99_us": 12049 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 309371, + "p50_us": 13341, + "p95_us": 122745, + "p99_us": 198738 + }, + "checkout_over_100us": 2487, + "checkpoint": [ + 0, + 14986, + 14986 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 569003, + "p50_us": 19844, + "p95_us": 249923, + "p99_us": 357322 + }, + "operations": 3200, + "operations_per_second": 143.6189365957794, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 51136, + "synchronous": "FULL", + "transaction": { + "max_us": 11933, + "p50_us": 1032, + "p95_us": 1783, + "p99_us": 2394 + }, + "wal_bytes": 61742352, + "workload": "mixed", + "write_wait": { + "max_us": 12091, + "p50_us": 5, + "p95_us": 133, + "p99_us": 1600 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 156, + "p50_us": 5, + "p95_us": 17, + "p99_us": 23 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1245326, + "p50_us": 4009, + "p95_us": 707043, + "p99_us": 994516 + }, + "operations": 3200, + "operations_per_second": 74.23347018831551, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 78704, + "synchronous": "FULL", + "transaction": { + "max_us": 21840, + "p50_us": 2815, + "p95_us": 5353, + "p99_us": 9732 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 4687, + "p50_us": 9, + "p95_us": 262, + "p99_us": 4638 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 4, + "p95_us": 10, + "p99_us": 19 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 23, + 23 + ], + "database_bytes": 13463552, + "failures": 0, + "latency": { + "max_us": 1260073, + "p50_us": 2214, + "p95_us": 65979, + "p99_us": 865687 + }, + "operations": 3200, + "operations_per_second": 214.6013920999969, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 64880, + "synchronous": "FULL", + "transaction": { + "max_us": 19575, + "p50_us": 2085, + "p95_us": 3536, + "p99_us": 4378 + }, + "wal_bytes": 173662152, + "workload": "write_burst", + "write_wait": { + "max_us": 148270, + "p50_us": 4, + "p95_us": 4698, + "p99_us": 49610 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 604, + "p50_us": 6, + "p95_us": 23, + "p99_us": 41 + }, + "checkout_over_100us": 4, + "checkpoint": [ + 0, + 14998, + 14998 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1544055, + "p50_us": 3591, + "p95_us": 748626, + "p99_us": 1089856 + }, + "operations": 3200, + "operations_per_second": 83.70383506253467, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 76704, + "synchronous": "FULL", + "transaction": { + "max_us": 72702, + "p50_us": 2837, + "p95_us": 5089, + "p99_us": 12883 + }, + "wal_bytes": 61791792, + "workload": "mixed", + "write_wait": { + "max_us": 63117, + "p50_us": 11, + "p95_us": 1673, + "p99_us": 10499 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 101, + "p50_us": 6, + "p95_us": 21, + "p99_us": 36 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1488034, + "p50_us": 4188, + "p95_us": 730465, + "p99_us": 1043183 + }, + "operations": 3200, + "operations_per_second": 71.77445111948836, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 78352, + "synchronous": "FULL", + "transaction": { + "max_us": 20697, + "p50_us": 2951, + "p95_us": 5176, + "p99_us": 10622 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 1623, + "p50_us": 10, + "p95_us": 232, + "p99_us": 273 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 208, + "p50_us": 4, + "p95_us": 12, + "p99_us": 22 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 267, + 267 + ], + "database_bytes": 13467648, + "failures": 0, + "latency": { + "max_us": 1187878, + "p50_us": 2296, + "p95_us": 66211, + "p99_us": 888892 + }, + "operations": 3200, + "operations_per_second": 204.55967564742696, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 73776, + "synchronous": "FULL", + "transaction": { + "max_us": 20024, + "p50_us": 2171, + "p95_us": 3531, + "p99_us": 4490 + }, + "wal_bytes": 172702192, + "workload": "write_burst", + "write_wait": { + "max_us": 227910, + "p50_us": 4, + "p95_us": 4683, + "p99_us": 27337 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 106, + "p50_us": 5, + "p95_us": 19, + "p99_us": 34 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 14984, + 14984 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1495203, + "p50_us": 3080, + "p95_us": 693122, + "p99_us": 1046100 + }, + "operations": 3200, + "operations_per_second": 88.61247979605173, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 79616, + "synchronous": "FULL", + "transaction": { + "max_us": 22725, + "p50_us": 2577, + "p95_us": 4254, + "p99_us": 11714 + }, + "wal_bytes": 61734112, + "workload": "mixed", + "write_wait": { + "max_us": 27418, + "p50_us": 9, + "p95_us": 1572, + "p99_us": 4718 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 64, + "p50_us": 5, + "p95_us": 20, + "p99_us": 22 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3095, + 3095 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1318983, + "p50_us": 4197, + "p95_us": 725376, + "p99_us": 1007996 + }, + "operations": 3200, + "operations_per_second": 72.34264708100383, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 76384, + "synchronous": "FULL", + "transaction": { + "max_us": 19297, + "p50_us": 2853, + "p95_us": 4925, + "p99_us": 9767 + }, + "wal_bytes": 12751432, + "workload": "read_heavy", + "write_wait": { + "max_us": 4605, + "p50_us": 11, + "p95_us": 202, + "p99_us": 1579 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 62, + "p50_us": 3, + "p95_us": 5, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 331, + 331 + ], + "database_bytes": 13467648, + "failures": 0, + "latency": { + "max_us": 1137207, + "p50_us": 1968, + "p95_us": 55281, + "p99_us": 788142 + }, + "operations": 3200, + "operations_per_second": 226.71084103788579, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 56000, + "synchronous": "FULL", + "transaction": { + "max_us": 14651, + "p50_us": 1888, + "p95_us": 2971, + "p99_us": 3751 + }, + "wal_bytes": 172780472, + "workload": "write_burst", + "write_wait": { + "max_us": 140706, + "p50_us": 3, + "p95_us": 4664, + "p99_us": 46449 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 74, + "p50_us": 4, + "p95_us": 14, + "p99_us": 21 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 92, + 92 + ], + "database_bytes": 13066240, + "failures": 0, + "latency": { + "max_us": 1242980, + "p50_us": 3015, + "p95_us": 658369, + "p99_us": 939466 + }, + "operations": 3200, + "operations_per_second": 93.16475825391993, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 79072, + "synchronous": "FULL", + "transaction": { + "max_us": 21313, + "p50_us": 2489, + "p95_us": 3793, + "p99_us": 5807 + }, + "wal_bytes": 61433352, + "workload": "mixed", + "write_wait": { + "max_us": 27375, + "p50_us": 10, + "p95_us": 1605, + "p99_us": 4646 + } + } + ], + "query_plans": [ + { + "details": [ + "SEARCH t USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SEARCH fp USING INDEX idx_posts_thread (thread_id=?) LEFT-JOIN", + "USE TEMP B-TREE FOR GROUP BY", + "USE TEMP B-TREE FOR count(DISTINCT)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "board_index" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "live_poll" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)" + ], + "name": "cooldown" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "latest_thread" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)" + ], + "name": "prune" + }, + { + "details": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "name": "search" + }, + { + "details": [ + "SEARCH reports USING INDEX idx_reports_status (status=?)" + ], + "name": "reports" + }, + { + "details": [ + "SEARCH bans USING INDEX idx_bans_ip (ip_hash=?)" + ], + "name": "bans" + }, + { + "details": [ + "SEARCH background_jobs USING INDEX idx_jobs_pending (status=?)" + ], + "name": "job_claim" + }, + { + "details": [ + "MULTI-INDEX OR", + "INDEX 1", + "SEARCH posts USING INDEX idx_posts_file_path (file_path=?)", + "INDEX 2", + "SEARCH posts USING INDEX idx_posts_thumb_path (thumb_path=?)", + "INDEX 3", + "SEARCH posts USING INDEX idx_posts_audio_file_path (audio_file_path=?)" + ], + "name": "file_refs" + }, + { + "details": [ + "SEARCH file_hashes USING INDEX sqlite_autoindex_file_hashes_1 (sha256=?)" + ], + "name": "dedup" + }, + { + "details": [ + "SCAN mod_log USING INDEX idx_mod_log_created" + ], + "name": "mod_log" + } + ], + "sqlite_version": "3.51.3", + "workers": 8 +} \ No newline at end of file diff --git a/docs/sqlite-evidence/final-matrix.json b/docs/sqlite-evidence/final-matrix.json new file mode 100644 index 00000000..5527844e --- /dev/null +++ b/docs/sqlite-evidence/final-matrix.json @@ -0,0 +1,1875 @@ +{ + "fixture": { + "boards": 8, + "posts": 40000, + "threads": 800 + }, + "limitations": "SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss", + "measurements": [ + { + "busy_errors": 0, + "checkout": { + "max_us": 23849, + "p50_us": 3, + "p95_us": 8089, + "p99_us": 14975 + }, + "checkout_over_100us": 1127, + "checkpoint": [ + 0, + 23, + 23 + ], + "checkpoint_us": 488, + "cpu_seconds": 1.73, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 24506, + "p50_us": 710, + "p95_us": 8933, + "p99_us": 15398 + }, + "operations": 3200, + "operations_per_second": 3545.295698279347, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 24506, + "p50_us": 715, + "p95_us": 8933, + "p99_us": 15398 + }, + "rss_kib": 68096, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9003, + "p50_us": 493, + "p95_us": 2105, + "p99_us": 8558 + }, + "wal_bytes": 5285992, + "workload": "read_heavy", + "write_latency": { + "max_us": 17964, + "p50_us": 588, + "p95_us": 8568, + "p99_us": 17312 + }, + "write_wait": { + "max_us": 4635, + "p50_us": 3, + "p95_us": 113, + "p99_us": 1605 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 29813, + "p50_us": 2247, + "p95_us": 10675, + "p99_us": 16328 + }, + "checkout_over_100us": 1834, + "checkpoint": [ + 0, + 726, + 726 + ], + "checkpoint_us": 3057, + "cpu_seconds": 1.7299999999999995, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 397696, + "p50_us": 2804, + "p95_us": 11863, + "p99_us": 20110 + }, + "operations": 3200, + "operations_per_second": 1861.4113333461153, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 31365, + "p50_us": 2811, + "p95_us": 11949, + "p99_us": 17895 + }, + "rss_kib": 61248, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8439, + "p50_us": 446, + "p95_us": 684, + "p99_us": 4483 + }, + "wal_bytes": 5426072, + "workload": "write_burst", + "write_latency": { + "max_us": 397696, + "p50_us": 2801, + "p95_us": 11794, + "p99_us": 21820 + }, + "write_wait": { + "max_us": 397097, + "p50_us": 4, + "p95_us": 30, + "p99_us": 12075 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 22625, + "p50_us": 3, + "p95_us": 9223, + "p99_us": 15908 + }, + "checkout_over_100us": 1380, + "checkpoint": [ + 0, + 166, + 166 + ], + "checkpoint_us": 1143, + "cpu_seconds": 1.8300000000000018, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 74561, + "p50_us": 1614, + "p95_us": 10289, + "p99_us": 16794 + }, + "operations": 3200, + "operations_per_second": 2757.439269554338, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 24271, + "p50_us": 1483, + "p95_us": 10086, + "p99_us": 16578 + }, + "rss_kib": 61360, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8219, + "p50_us": 454, + "p95_us": 1355, + "p99_us": 3641 + }, + "wal_bytes": 6105872, + "workload": "mixed", + "write_latency": { + "max_us": 74561, + "p50_us": 1985, + "p95_us": 11060, + "p99_us": 19046 + }, + "write_wait": { + "max_us": 73915, + "p50_us": 3, + "p95_us": 1569, + "p99_us": 4585 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 22770, + "p50_us": 2, + "p95_us": 3953, + "p99_us": 7352 + }, + "checkout_over_100us": 801, + "checkpoint": [ + 0, + 28, + 28 + ], + "checkpoint_us": 480, + "cpu_seconds": 2.020000000000003, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 24749, + "p50_us": 740, + "p95_us": 4987, + "p99_us": 8805 + }, + "operations": 3200, + "operations_per_second": 5566.67682875119, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 21390, + "p50_us": 728, + "p95_us": 4719, + "p99_us": 8623 + }, + "rss_kib": 77856, + "synchronous": "NORMAL", + "transaction": { + "max_us": 13756, + "p50_us": 757, + "p95_us": 1207, + "p99_us": 8760 + }, + "wal_bytes": 14139872, + "workload": "read_heavy", + "write_latency": { + "max_us": 24749, + "p50_us": 936, + "p95_us": 6460, + "p99_us": 24520 + }, + "write_wait": { + "max_us": 23910, + "p50_us": 3, + "p95_us": 1610, + "p99_us": 12093 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 16041, + "p50_us": 483, + "p95_us": 6751, + "p99_us": 10607 + }, + "checkout_over_100us": 1609, + "checkpoint": [ + 0, + 518, + 518 + ], + "checkpoint_us": 1912, + "cpu_seconds": 1.759999999999998, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 701059, + "p50_us": 1744, + "p95_us": 8915, + "p99_us": 26124 + }, + "operations": 3200, + "operations_per_second": 1900.1210288027173, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 17763, + "p50_us": 1684, + "p95_us": 7322, + "p99_us": 10997 + }, + "rss_kib": 88512, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9543, + "p50_us": 451, + "p95_us": 762, + "p99_us": 4284 + }, + "wal_bytes": 5166512, + "workload": "write_burst", + "write_latency": { + "max_us": 701059, + "p50_us": 1782, + "p95_us": 9683, + "p99_us": 49594 + }, + "write_wait": { + "max_us": 700476, + "p50_us": 3, + "p95_us": 1595, + "p99_us": 47076 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 17908, + "p50_us": 3, + "p95_us": 5550, + "p99_us": 9382 + }, + "checkout_over_100us": 1187, + "checkpoint": [ + 0, + 168, + 168 + ], + "checkpoint_us": 1090, + "cpu_seconds": 1.980000000000004, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 288672, + "p50_us": 936, + "p95_us": 7323, + "p99_us": 13750 + }, + "operations": 3200, + "operations_per_second": 3214.506934787404, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 19506, + "p50_us": 871, + "p95_us": 6484, + "p99_us": 10402 + }, + "rss_kib": 66528, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8444, + "p50_us": 510, + "p95_us": 1701, + "p99_us": 3942 + }, + "wal_bytes": 10506032, + "workload": "mixed", + "write_latency": { + "max_us": 288672, + "p50_us": 1825, + "p95_us": 12554, + "p99_us": 39675 + }, + "write_wait": { + "max_us": 288069, + "p50_us": 4, + "p95_us": 11325, + "p99_us": 39170 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 57, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3460, + 3460 + ], + "checkpoint_us": 10, + "cpu_seconds": 3.4499999999999957, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 71663, + "p50_us": 830, + "p95_us": 3695, + "p99_us": 6625 + }, + "operations": 3200, + "operations_per_second": 5361.656659955514, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 13099, + "p50_us": 804, + "p95_us": 3502, + "p99_us": 4826 + }, + "rss_kib": 78752, + "synchronous": "NORMAL", + "transaction": { + "max_us": 13503, + "p50_us": 1682, + "p95_us": 2988, + "p99_us": 10885 + }, + "wal_bytes": 14255232, + "workload": "read_heavy", + "write_latency": { + "max_us": 71663, + "p50_us": 2504, + "p95_us": 12581, + "p99_us": 51802 + }, + "write_wait": { + "max_us": 70904, + "p50_us": 54, + "p95_us": 11657, + "p99_us": 49694 + } + }, + { + "busy_errors": 1, + "checkout": { + "max_us": 58, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 325, + 325 + ], + "checkpoint_us": 1830, + "cpu_seconds": 1.7700000000000031, + "database_bytes": 15204352, + "failures": 1, + "latency": { + "max_us": 1080606, + "p50_us": 458, + "p95_us": 5017, + "p99_us": 72699 + }, + "operations": 3200, + "operations_per_second": 1925.477142821244, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 6859, + "p50_us": 431, + "p95_us": 1759, + "p99_us": 2040 + }, + "rss_kib": 102960, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9169, + "p50_us": 451, + "p95_us": 976, + "p99_us": 3970 + }, + "wal_bytes": 5928712, + "workload": "write_burst", + "write_latency": { + "max_us": 1080606, + "p50_us": 461, + "p95_us": 7942, + "p99_us": 105466 + }, + "write_wait": { + "max_us": 1080400, + "p50_us": 3, + "p95_us": 4609, + "p99_us": 104964 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 31, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 330, + 330 + ], + "checkpoint_us": 2882, + "cpu_seconds": 2.039999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 386547, + "p50_us": 559, + "p95_us": 4659, + "p99_us": 27480 + }, + "operations": 3200, + "operations_per_second": 3463.3115342656574, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 13194, + "p50_us": 430, + "p95_us": 1795, + "p99_us": 2007 + }, + "rss_kib": 86224, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9862, + "p50_us": 545, + "p95_us": 1528, + "p99_us": 3629 + }, + "wal_bytes": 11408312, + "workload": "mixed", + "write_latency": { + "max_us": 386547, + "p50_us": 765, + "p95_us": 25215, + "p99_us": 108468 + }, + "write_wait": { + "max_us": 386003, + "p50_us": 44, + "p95_us": 24639, + "p99_us": 107800 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 33, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 26, + 26 + ], + "checkpoint_us": 587, + "cpu_seconds": 3.4399999999999977, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 81566, + "p50_us": 829, + "p95_us": 3801, + "p99_us": 7255 + }, + "operations": 3200, + "operations_per_second": 5208.924322422961, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 13570, + "p50_us": 806, + "p95_us": 3506, + "p99_us": 4667 + }, + "rss_kib": 107728, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10012, + "p50_us": 1839, + "p95_us": 4424, + "p99_us": 7194 + }, + "wal_bytes": 14148112, + "workload": "read_heavy", + "write_latency": { + "max_us": 81566, + "p50_us": 2777, + "p95_us": 16184, + "p99_us": 51479 + }, + "write_wait": { + "max_us": 80043, + "p50_us": 104, + "p95_us": 12198, + "p99_us": 48423 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 22, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 570, + 570 + ], + "checkpoint_us": 2417, + "cpu_seconds": 1.7600000000000051, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 610550, + "p50_us": 457, + "p95_us": 4547, + "p99_us": 49485 + }, + "operations": 3200, + "operations_per_second": 1902.9916784695365, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 7921, + "p50_us": 435, + "p95_us": 1779, + "p99_us": 2276 + }, + "rss_kib": 127328, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9147, + "p50_us": 450, + "p95_us": 898, + "p99_us": 4307 + }, + "wal_bytes": 5846312, + "workload": "write_burst", + "write_latency": { + "max_us": 610550, + "p50_us": 459, + "p95_us": 5257, + "p99_us": 76745 + }, + "write_wait": { + "max_us": 609884, + "p50_us": 3, + "p95_us": 4570, + "p99_us": 76135 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 29, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 109, + 109 + ], + "checkpoint_us": 881, + "cpu_seconds": 2.030000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 291727, + "p50_us": 562, + "p95_us": 4887, + "p99_us": 27227 + }, + "operations": 3200, + "operations_per_second": 3344.4141752781616, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11975, + "p50_us": 428, + "p95_us": 1807, + "p99_us": 2050 + }, + "rss_kib": 73968, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10487, + "p50_us": 551, + "p95_us": 1518, + "p99_us": 3783 + }, + "wal_bytes": 12640192, + "workload": "mixed", + "write_latency": { + "max_us": 291727, + "p50_us": 793, + "p95_us": 23091, + "p99_us": 101922 + }, + "write_wait": { + "max_us": 291145, + "p50_us": 46, + "p95_us": 22423, + "p99_us": 101489 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 33, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3458, + 3458 + ], + "checkpoint_us": 11, + "cpu_seconds": 3.5600000000000023, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 61147, + "p50_us": 861, + "p95_us": 3802, + "p99_us": 6652 + }, + "operations": 3200, + "operations_per_second": 5304.8888112960485, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 14500, + "p50_us": 830, + "p95_us": 3603, + "p99_us": 4744 + }, + "rss_kib": 96272, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12877, + "p50_us": 1702, + "p95_us": 3406, + "p99_us": 11156 + }, + "wal_bytes": 14246992, + "workload": "read_heavy", + "write_latency": { + "max_us": 61147, + "p50_us": 2612, + "p95_us": 14092, + "p99_us": 46223 + }, + "write_wait": { + "max_us": 48266, + "p50_us": 107, + "p95_us": 12214, + "p99_us": 44066 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 42, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 150, + 150 + ], + "checkpoint_us": 965, + "cpu_seconds": 1.7199999999999989, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 1112467, + "p50_us": 456, + "p95_us": 4957, + "p99_us": 49303 + }, + "operations": 3200, + "operations_per_second": 1919.5566304072916, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 6593, + "p50_us": 434, + "p95_us": 1739, + "p99_us": 2184 + }, + "rss_kib": 110064, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8473, + "p50_us": 448, + "p95_us": 835, + "p99_us": 4021 + }, + "wal_bytes": 5578512, + "workload": "write_burst", + "write_latency": { + "max_us": 1112467, + "p50_us": 459, + "p95_us": 5195, + "p99_us": 90894 + }, + "write_wait": { + "max_us": 1111916, + "p50_us": 3, + "p95_us": 4571, + "p99_us": 90383 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 29, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 414, + 414 + ], + "checkpoint_us": 2139, + "cpu_seconds": 2.030000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 283508, + "p50_us": 618, + "p95_us": 4979, + "p99_us": 27026 + }, + "operations": 3200, + "operations_per_second": 3486.4579908467167, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12515, + "p50_us": 427, + "p95_us": 1796, + "p99_us": 2033 + }, + "rss_kib": 85104, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8696, + "p50_us": 570, + "p95_us": 1410, + "p99_us": 3296 + }, + "wal_bytes": 11853272, + "workload": "mixed", + "write_latency": { + "max_us": 283508, + "p50_us": 849, + "p95_us": 25972, + "p99_us": 129948 + }, + "write_wait": { + "max_us": 283079, + "p50_us": 54, + "p95_us": 25075, + "p99_us": 129436 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 35149, + "p50_us": 3, + "p95_us": 8272, + "p99_us": 14254 + }, + "checkout_over_100us": 1166, + "checkpoint": [ + 0, + 722, + 722 + ], + "checkpoint_us": 2737, + "cpu_seconds": 1.789999999999992, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 35871, + "p50_us": 732, + "p95_us": 8979, + "p99_us": 15365 + }, + "operations": 3200, + "operations_per_second": 3416.955152715621, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 35871, + "p50_us": 732, + "p95_us": 8861, + "p99_us": 15047 + }, + "rss_kib": 76704, + "synchronous": "FULL", + "transaction": { + "max_us": 10082, + "p50_us": 601, + "p95_us": 1215, + "p99_us": 5977 + }, + "wal_bytes": 6641472, + "workload": "read_heavy", + "write_latency": { + "max_us": 22813, + "p50_us": 748, + "p95_us": 11023, + "p99_us": 19223 + }, + "write_wait": { + "max_us": 9320, + "p50_us": 3, + "p95_us": 1565, + "p99_us": 4621 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 40935, + "p50_us": 2847, + "p95_us": 11373, + "p99_us": 17688 + }, + "checkout_over_100us": 1995, + "checkpoint": [ + 0, + 920, + 920 + ], + "checkpoint_us": 2673, + "cpu_seconds": 1.9100000000000108, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 278449, + "p50_us": 3463, + "p95_us": 12971, + "p99_us": 21816 + }, + "operations": 3200, + "operations_per_second": 1605.6341703035953, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 41232, + "p50_us": 3492, + "p95_us": 12971, + "p99_us": 20249 + }, + "rss_kib": 78688, + "synchronous": "FULL", + "transaction": { + "max_us": 9026, + "p50_us": 520, + "p95_us": 792, + "p99_us": 3783 + }, + "wal_bytes": 4754512, + "workload": "write_burst", + "write_latency": { + "max_us": 278449, + "p50_us": 3439, + "p95_us": 12971, + "p99_us": 24596 + }, + "write_wait": { + "max_us": 273109, + "p50_us": 3, + "p95_us": 24, + "p99_us": 12111 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 36738, + "p50_us": 3, + "p95_us": 9192, + "p99_us": 16757 + }, + "checkout_over_100us": 1372, + "checkpoint": [ + 0, + 992, + 992 + ], + "checkpoint_us": 2936, + "cpu_seconds": 1.8400000000000034, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 49481, + "p50_us": 1567, + "p95_us": 10033, + "p99_us": 17866 + }, + "operations": 3200, + "operations_per_second": 2733.9587904838154, + "pool_size": 2, + "pool_timeouts": 0, + "read_latency": { + "max_us": 37083, + "p50_us": 1510, + "p95_us": 9608, + "p99_us": 17091 + }, + "rss_kib": 73456, + "synchronous": "FULL", + "transaction": { + "max_us": 9113, + "p50_us": 497, + "p95_us": 787, + "p99_us": 3621 + }, + "wal_bytes": 5582632, + "workload": "mixed", + "write_latency": { + "max_us": 49481, + "p50_us": 2008, + "p95_us": 10656, + "p99_us": 20085 + }, + "write_wait": { + "max_us": 48950, + "p50_us": 3, + "p95_us": 1592, + "p99_us": 4585 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 21648, + "p50_us": 2, + "p95_us": 4062, + "p99_us": 7876 + }, + "checkout_over_100us": 784, + "checkpoint": [ + 0, + 785, + 785 + ], + "checkpoint_us": 2717, + "cpu_seconds": 2.0, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 25794, + "p50_us": 743, + "p95_us": 5024, + "p99_us": 9152 + }, + "operations": 3200, + "operations_per_second": 5727.091132113926, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 22022, + "p50_us": 735, + "p95_us": 4870, + "p99_us": 8949 + }, + "rss_kib": 68512, + "synchronous": "FULL", + "transaction": { + "max_us": 9351, + "p50_us": 706, + "p95_us": 1257, + "p99_us": 6909 + }, + "wal_bytes": 11021032, + "workload": "read_heavy", + "write_latency": { + "max_us": 25794, + "p50_us": 945, + "p95_us": 7490, + "p99_us": 12064 + }, + "write_wait": { + "max_us": 25223, + "p50_us": 3, + "p95_us": 1598, + "p99_us": 9872 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 19272, + "p50_us": 609, + "p95_us": 6176, + "p99_us": 10195 + }, + "checkout_over_100us": 1616, + "checkpoint": [ + 0, + 24, + 24 + ], + "checkpoint_us": 414, + "cpu_seconds": 1.740000000000009, + "database_bytes": 15216640, + "failures": 0, + "latency": { + "max_us": 593160, + "p50_us": 1792, + "p95_us": 8843, + "p99_us": 46751 + }, + "operations": 3200, + "operations_per_second": 1853.5860124330882, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 15461, + "p50_us": 1714, + "p95_us": 6888, + "p99_us": 11052 + }, + "rss_kib": 86896, + "synchronous": "FULL", + "transaction": { + "max_us": 11236, + "p50_us": 479, + "p95_us": 733, + "p99_us": 3343 + }, + "wal_bytes": 5384872, + "workload": "write_burst", + "write_latency": { + "max_us": 593160, + "p50_us": 1852, + "p95_us": 10413, + "p99_us": 66414 + }, + "write_wait": { + "max_us": 592456, + "p50_us": 3, + "p95_us": 4554, + "p99_us": 65826 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 24922, + "p50_us": 3, + "p95_us": 5396, + "p99_us": 9175 + }, + "checkout_over_100us": 1072, + "checkpoint": [ + 0, + 20, + 20 + ], + "checkpoint_us": 316, + "cpu_seconds": 1.8700000000000045, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 823226, + "p50_us": 785, + "p95_us": 6968, + "p99_us": 12556 + }, + "operations": 3200, + "operations_per_second": 2995.898195009067, + "pool_size": 4, + "pool_timeouts": 0, + "read_latency": { + "max_us": 25499, + "p50_us": 750, + "p95_us": 6097, + "p99_us": 10488 + }, + "rss_kib": 80160, + "synchronous": "FULL", + "transaction": { + "max_us": 8408, + "p50_us": 506, + "p95_us": 1126, + "p99_us": 3581 + }, + "wal_bytes": 9331832, + "workload": "mixed", + "write_latency": { + "max_us": 823226, + "p50_us": 1530, + "p95_us": 9598, + "p99_us": 27161 + }, + "write_wait": { + "max_us": 822049, + "p50_us": 3, + "p95_us": 4569, + "p99_us": 26619 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 104, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 3463, + 3463 + ], + "checkpoint_us": 3261, + "cpu_seconds": 3.3499999999999943, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 115560, + "p50_us": 817, + "p95_us": 3451, + "p99_us": 6692 + }, + "operations": 3200, + "operations_per_second": 5545.922403880066, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 13345, + "p50_us": 794, + "p95_us": 3307, + "p99_us": 4066 + }, + "rss_kib": 86832, + "synchronous": "FULL", + "transaction": { + "max_us": 11415, + "p50_us": 1570, + "p95_us": 3424, + "p99_us": 10332 + }, + "wal_bytes": 14267592, + "workload": "read_heavy", + "write_latency": { + "max_us": 115560, + "p50_us": 2128, + "p95_us": 13494, + "p99_us": 81868 + }, + "write_wait": { + "max_us": 114729, + "p50_us": 53, + "p95_us": 12164, + "p99_us": 79781 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 50, + "p50_us": 3, + "p95_us": 4, + "p99_us": 7 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 736, + 736 + ], + "checkpoint_us": 2191, + "cpu_seconds": 1.7800000000000011, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 1047420, + "p50_us": 493, + "p95_us": 3270, + "p99_us": 45375 + }, + "operations": 3200, + "operations_per_second": 1793.6255385757938, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12327, + "p50_us": 443, + "p95_us": 1745, + "p99_us": 1944 + }, + "rss_kib": 87056, + "synchronous": "FULL", + "transaction": { + "max_us": 11604, + "p50_us": 488, + "p95_us": 779, + "p99_us": 3601 + }, + "wal_bytes": 5022312, + "workload": "write_burst", + "write_latency": { + "max_us": 1047420, + "p50_us": 498, + "p95_us": 5120, + "p99_us": 50213 + }, + "write_wait": { + "max_us": 1046725, + "p50_us": 3, + "p95_us": 4556, + "p99_us": 49627 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 313, + "p50_us": 3, + "p95_us": 5, + "p99_us": 16 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 872, + 872 + ], + "checkpoint_us": 3147, + "cpu_seconds": 2.280000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 512019, + "p50_us": 654, + "p95_us": 5431, + "p99_us": 28246 + }, + "operations": 3200, + "operations_per_second": 2625.7450687022424, + "pool_size": 8, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12706, + "p50_us": 481, + "p95_us": 2002, + "p99_us": 5266 + }, + "rss_kib": 77872, + "synchronous": "FULL", + "transaction": { + "max_us": 15399, + "p50_us": 635, + "p95_us": 2151, + "p99_us": 6716 + }, + "wal_bytes": 9817992, + "workload": "mixed", + "write_latency": { + "max_us": 512019, + "p50_us": 942, + "p95_us": 26716, + "p99_us": 135339 + }, + "write_wait": { + "max_us": 511445, + "p50_us": 66, + "p95_us": 25976, + "p99_us": 134703 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 53, + "p50_us": 3, + "p95_us": 4, + "p99_us": 14 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 49, + 49 + ], + "checkpoint_us": 890, + "cpu_seconds": 3.579999999999984, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 107255, + "p50_us": 922, + "p95_us": 4158, + "p99_us": 7918 + }, + "operations": 3200, + "operations_per_second": 4734.7854328407675, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 14414, + "p50_us": 870, + "p95_us": 3864, + "p99_us": 5094 + }, + "rss_kib": 81344, + "synchronous": "FULL", + "transaction": { + "max_us": 11154, + "p50_us": 1916, + "p95_us": 4434, + "p99_us": 9905 + }, + "wal_bytes": 14065712, + "workload": "read_heavy", + "write_latency": { + "max_us": 107255, + "p50_us": 2688, + "p95_us": 27281, + "p99_us": 100857 + }, + "write_wait": { + "max_us": 105378, + "p50_us": 75, + "p95_us": 25124, + "p99_us": 99106 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 25, + "p50_us": 3, + "p95_us": 5, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 21, + 21 + ], + "checkpoint_us": 623, + "cpu_seconds": 2.0500000000000114, + "database_bytes": 15192064, + "failures": 0, + "latency": { + "max_us": 946846, + "p50_us": 578, + "p95_us": 5205, + "p99_us": 105659 + }, + "operations": 3200, + "operations_per_second": 1563.5547444085446, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 7275, + "p50_us": 507, + "p95_us": 1998, + "p99_us": 2412 + }, + "rss_kib": 81456, + "synchronous": "FULL", + "transaction": { + "max_us": 9513, + "p50_us": 565, + "p95_us": 1241, + "p99_us": 4189 + }, + "wal_bytes": 5228312, + "workload": "write_burst", + "write_latency": { + "max_us": 946846, + "p50_us": 583, + "p95_us": 11987, + "p99_us": 137035 + }, + "write_wait": { + "max_us": 946021, + "p50_us": 3, + "p95_us": 11276, + "p99_us": 136408 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 63, + "p50_us": 3, + "p95_us": 4, + "p99_us": 8 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 599, + 599 + ], + "checkpoint_us": 2425, + "cpu_seconds": 2.1499999999999773, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 602536, + "p50_us": 616, + "p95_us": 5143, + "p99_us": 27726 + }, + "operations": 3200, + "operations_per_second": 2825.104200459125, + "pool_size": 12, + "pool_timeouts": 0, + "read_latency": { + "max_us": 12409, + "p50_us": 466, + "p95_us": 1911, + "p99_us": 2337 + }, + "rss_kib": 73056, + "synchronous": "FULL", + "transaction": { + "max_us": 8758, + "p50_us": 619, + "p95_us": 1508, + "p99_us": 3671 + }, + "wal_bytes": 12961552, + "workload": "mixed", + "write_latency": { + "max_us": 602536, + "p50_us": 887, + "p95_us": 26186, + "p99_us": 107093 + }, + "write_wait": { + "max_us": 601905, + "p50_us": 62, + "p95_us": 25603, + "p99_us": 106435 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 105, + "p50_us": 2, + "p95_us": 4, + "p99_us": 18 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 412, + 412 + ], + "checkpoint_us": 2239, + "cpu_seconds": 3.4599999999999795, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 103559, + "p50_us": 849, + "p95_us": 3977, + "p99_us": 8875 + }, + "operations": 3200, + "operations_per_second": 4862.84867194843, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 14364, + "p50_us": 822, + "p95_us": 3652, + "p99_us": 5231 + }, + "rss_kib": 88704, + "synchronous": "FULL", + "transaction": { + "max_us": 11824, + "p50_us": 1625, + "p95_us": 4550, + "p99_us": 8829 + }, + "wal_bytes": 12557792, + "workload": "read_heavy", + "write_latency": { + "max_us": 103559, + "p50_us": 2566, + "p95_us": 26969, + "p99_us": 77162 + }, + "write_wait": { + "max_us": 101640, + "p50_us": 73, + "p95_us": 23178, + "p99_us": 75214 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 25, + "p50_us": 3, + "p95_us": 4, + "p99_us": 10 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 994, + 994 + ], + "checkpoint_us": 3263, + "cpu_seconds": 1.9799999999999898, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 817040, + "p50_us": 540, + "p95_us": 5154, + "p99_us": 75740 + }, + "operations": 3200, + "operations_per_second": 1649.081758063573, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 7651, + "p50_us": 476, + "p95_us": 1931, + "p99_us": 2405 + }, + "rss_kib": 88816, + "synchronous": "FULL", + "transaction": { + "max_us": 11961, + "p50_us": 533, + "p95_us": 957, + "p99_us": 3969 + }, + "wal_bytes": 5562032, + "workload": "write_burst", + "write_latency": { + "max_us": 817040, + "p50_us": 544, + "p95_us": 5899, + "p99_us": 108367 + }, + "write_wait": { + "max_us": 815895, + "p50_us": 3, + "p95_us": 4622, + "p99_us": 107852 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 70, + "p50_us": 3, + "p95_us": 4, + "p99_us": 9 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 977, + 977 + ], + "checkpoint_us": 2798, + "cpu_seconds": 2.1999999999999886, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 499719, + "p50_us": 638, + "p95_us": 4392, + "p99_us": 27164 + }, + "operations": 3200, + "operations_per_second": 2934.8208382261573, + "pool_size": 16, + "pool_timeouts": 0, + "read_latency": { + "max_us": 11853, + "p50_us": 476, + "p95_us": 1952, + "p99_us": 2273 + }, + "rss_kib": 69872, + "synchronous": "FULL", + "transaction": { + "max_us": 9114, + "p50_us": 624, + "p95_us": 1352, + "p99_us": 4747 + }, + "wal_bytes": 9455432, + "workload": "mixed", + "write_latency": { + "max_us": 499719, + "p50_us": 851, + "p95_us": 25858, + "p99_us": 105171 + }, + "write_wait": { + "max_us": 499093, + "p50_us": 67, + "p95_us": 25340, + "p99_us": 104568 + } + } + ], + "query_plans": [ + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "board_index" + }, + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "catalog" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "thread_loading" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "name": "latest_post" + }, + { + "details": [ + "SEARCH threads USING INTEGER PRIMARY KEY (rowid=?)", + "SCALAR SUBQUERY 1", + "SEARCH boards USING INTEGER PRIMARY KEY (rowid=?)" + ], + "name": "thread_bump" + }, + { + "details": [ + "SCAN CONSTANT ROW", + "SCALAR SUBQUERY 1", + "SCAN posts USING COVERING INDEX idx_posts_board", + "SCALAR SUBQUERY 2", + "SCAN threads USING COVERING INDEX idx_threads_archived", + "SCALAR SUBQUERY 3", + "SEARCH reports USING COVERING INDEX idx_reports_status (status=?)" + ], + "name": "admin_overview" + }, + { + "details": [ + "SEARCH reports USING COVERING INDEX idx_reports_open_unique (post_id=? AND reporter_hash=?)" + ], + "name": "report_lookup" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "backup_board_inventory" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "name": "live_poll" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "name": "cooldown" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "latest_thread" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)" + ], + "name": "prune" + }, + { + "details": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "search_selective" + }, + { + "details": [ + "SEARCH reports USING INDEX idx_reports_status (status=?)" + ], + "name": "reports" + }, + { + "details": [ + "SEARCH bans USING INDEX idx_bans_ip (ip_hash=?)" + ], + "name": "bans" + }, + { + "details": [ + "SEARCH background_jobs USING INDEX idx_jobs_pending (status=?)" + ], + "name": "job_claim" + }, + { + "details": [ + "MULTI-INDEX OR", + "INDEX 1", + "SEARCH posts USING INDEX idx_posts_file_path (file_path=?)", + "INDEX 2", + "SEARCH posts USING INDEX idx_posts_thumb_path (thumb_path=?)", + "INDEX 3", + "SEARCH posts USING INDEX idx_posts_audio_file_path (audio_file_path=?)" + ], + "name": "file_refs" + }, + { + "details": [ + "SEARCH file_hashes USING INDEX sqlite_autoindex_file_hashes_1 (sha256=?)" + ], + "name": "dedup" + }, + { + "details": [ + "SCAN mod_log USING INDEX idx_mod_log_created" + ], + "name": "mod_log" + } + ], + "sqlite_version": "3.51.3", + "workers": 8 +} \ No newline at end of file diff --git a/docs/sqlite-evidence/index-profile.json b/docs/sqlite-evidence/index-profile.json new file mode 100644 index 00000000..26bef657 --- /dev/null +++ b/docs/sqlite-evidence/index-profile.json @@ -0,0 +1,240 @@ +{ + "database_bytes_after": 14237696, + "database_bytes_before": 13066240, + "queries": [ + { + "after": { + "max_us": 615, + "p50_us": 265, + "p95_us": 289, + "p99_us": 615 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 24659, + "p50_us": 24246, + "p95_us": 24552, + "p99_us": 24659 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_needle" + }, + { + "after": { + "max_us": 377, + "p50_us": 238, + "p95_us": 303, + "p99_us": 377 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 43439, + "p50_us": 42653, + "p95_us": 43424, + "p99_us": 43439 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_needle" + }, + { + "after": { + "max_us": 9327, + "p50_us": 8878, + "p95_us": 9109, + "p99_us": 9327 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 272, + "p50_us": 244, + "p95_us": 270, + "p99_us": 272 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_representative" + }, + { + "after": { + "max_us": 7151, + "p50_us": 6779, + "p95_us": 6872, + "p99_us": 7151 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 59522, + "p50_us": 58353, + "p95_us": 58803, + "p99_us": 59522 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_representative" + }, + { + "after": { + "max_us": 30, + "p50_us": 8, + "p95_us": 13, + "p99_us": 30 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 18506, + "p50_us": 18177, + "p95_us": 18403, + "p99_us": 18506 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_missingword" + }, + { + "after": { + "max_us": 9, + "p50_us": 6, + "p95_us": 6, + "p99_us": 9 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 18411, + "p50_us": 18219, + "p95_us": 18403, + "p99_us": 18411 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_missingword" + }, + { + "after": { + "max_us": 149, + "p50_us": 139, + "p95_us": 149, + "p99_us": 149 + }, + "after_plan": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "before": { + "max_us": 5533, + "p50_us": 5341, + "p95_us": 5446, + "p99_us": 5533 + }, + "before_plan": [ + "SEARCH t USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SEARCH fp USING INDEX idx_posts_thread (thread_id=?) LEFT-JOIN", + "USE TEMP B-TREE FOR GROUP BY", + "USE TEMP B-TREE FOR count(DISTINCT)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "query": "board_page" + }, + { + "after": { + "max_us": 54, + "p50_us": 5, + "p95_us": 7, + "p99_us": 54 + }, + "after_plan": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "before": { + "max_us": 2877, + "p50_us": 2728, + "p95_us": 2821, + "p99_us": 2877 + }, + "before_plan": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "query": "live_poll" + }, + { + "after": { + "max_us": 27, + "p50_us": 2, + "p95_us": 3, + "p99_us": 27 + }, + "after_plan": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "before": { + "max_us": 181, + "p50_us": 155, + "p95_us": 167, + "p99_us": 181 + }, + "before_plan": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)" + ], + "query": "cooldown" + } + ], + "sqlite": "3.51.3", + "write_after": { + "max_us": 3536, + "p50_us": 373, + "p95_us": 525, + "p99_us": 3064 + }, + "write_before": { + "max_us": 7394, + "p50_us": 321, + "p95_us": 491, + "p99_us": 3614 + } +} \ No newline at end of file diff --git a/docs/sqlite-evidence/media-baseline.json b/docs/sqlite-evidence/media-baseline.json new file mode 100644 index 00000000..179ada39 --- /dev/null +++ b/docs/sqlite-evidence/media-baseline.json @@ -0,0 +1,89 @@ +[ + { + "pending_fs_ops": 0, + "pool_size": 2, + "pool_timeouts": 20, + "posting": { + "max_us": 1382414, + "p50_us": 1369530, + "p95_us": 1382414, + "p99_us": 1382414, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 1382262, + "p50_us": 1357904, + "p95_us": 1382262, + "p99_us": 1382262, + "samples": 12 + }, + "posts": 8, + "reader_checkout": { + "max_us": 735546, + "p50_us": 7584, + "p95_us": 374829, + "p99_us": 735546, + "samples": 34 + }, + "reads": 34, + "seconds": 5.483942458 + }, + { + "pending_fs_ops": 0, + "pool_size": 4, + "pool_timeouts": 0, + "posting": { + "max_us": 357451, + "p50_us": 349984, + "p95_us": 357451, + "p99_us": 357451, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 357447, + "p50_us": 1335, + "p95_us": 357447, + "p99_us": 357447, + "samples": 16 + }, + "posts": 8, + "reader_checkout": { + "max_us": 189, + "p50_us": 8, + "p95_us": 58, + "p99_us": 93, + "samples": 2240 + }, + "reads": 2240, + "seconds": 1.412153167 + }, + { + "pending_fs_ops": 0, + "pool_size": 8, + "pool_timeouts": 0, + "posting": { + "max_us": 695160, + "p50_us": 598755, + "p95_us": 695160, + "p99_us": 695160, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 695155, + "p50_us": 166, + "p95_us": 695155, + "p99_us": 695155, + "samples": 16 + }, + "posts": 8, + "reader_checkout": { + "max_us": 20104, + "p50_us": 15, + "p95_us": 47, + "p99_us": 10037, + "samples": 3339 + }, + "reads": 3339, + "seconds": 2.4163724589999998 + } +] \ No newline at end of file diff --git a/docs/sqlite-evidence/media-optimized.json b/docs/sqlite-evidence/media-optimized.json new file mode 100644 index 00000000..28bee2c3 --- /dev/null +++ b/docs/sqlite-evidence/media-optimized.json @@ -0,0 +1,182 @@ +[ + { + "pending_fs_ops": 0, + "pool_size": 2, + "pool_timeouts": 0, + "posting": { + "max_us": 382820, + "p50_us": 346854, + "p95_us": 382820, + "p99_us": 382820, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 4657, + "p50_us": 45, + "p95_us": 1620, + "p99_us": 4657, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 1140, + "p50_us": 3, + "p95_us": 22, + "p99_us": 32, + "samples": 1930 + }, + "reads": 1930, + "seconds": 1.423081083, + "synchronous": "NORMAL" + }, + { + "pending_fs_ops": 0, + "pool_size": 4, + "pool_timeouts": 0, + "posting": { + "max_us": 349927, + "p50_us": 347987, + "p95_us": 349927, + "p99_us": 349927, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 2324, + "p50_us": 46, + "p95_us": 2203, + "p99_us": 2324, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 123, + "p50_us": 3, + "p95_us": 4, + "p99_us": 11, + "samples": 1876 + }, + "reads": 1876, + "seconds": 1.39533675, + "synchronous": "NORMAL" + }, + { + "pending_fs_ops": 0, + "pool_size": 8, + "pool_timeouts": 0, + "posting": { + "max_us": 352702, + "p50_us": 347864, + "p95_us": 352702, + "p99_us": 352702, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 4517, + "p50_us": 47, + "p95_us": 1598, + "p99_us": 4517, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 44, + "p50_us": 3, + "p95_us": 5, + "p99_us": 13, + "samples": 1894 + }, + "reads": 1894, + "seconds": 1.395383542, + "synchronous": "NORMAL" + }, + { + "pending_fs_ops": 0, + "pool_size": 2, + "pool_timeouts": 0, + "posting": { + "max_us": 352849, + "p50_us": 347823, + "p95_us": 352849, + "p99_us": 352849, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 4428, + "p50_us": 48, + "p95_us": 1626, + "p99_us": 4428, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 654, + "p50_us": 3, + "p95_us": 23, + "p99_us": 35, + "samples": 1912 + }, + "reads": 1912, + "seconds": 1.401341458, + "synchronous": "FULL" + }, + { + "pending_fs_ops": 0, + "pool_size": 4, + "pool_timeouts": 0, + "posting": { + "max_us": 356356, + "p50_us": 349336, + "p95_us": 356356, + "p99_us": 356356, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 5394, + "p50_us": 54, + "p95_us": 1991, + "p99_us": 5394, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 135, + "p50_us": 3, + "p95_us": 5, + "p99_us": 14, + "samples": 1910 + }, + "reads": 1910, + "seconds": 1.408157208, + "synchronous": "FULL" + }, + { + "pending_fs_ops": 0, + "pool_size": 8, + "pool_timeouts": 0, + "posting": { + "max_us": 351161, + "p50_us": 349132, + "p95_us": 351161, + "p99_us": 351161, + "samples": 8 + }, + "posting_connection_holds": { + "max_us": 2451, + "p50_us": 55, + "p95_us": 1670, + "p99_us": 2451, + "samples": 64 + }, + "posts": 8, + "reader_checkout": { + "max_us": 60, + "p50_us": 3, + "p95_us": 5, + "p99_us": 10, + "samples": 1896 + }, + "reads": 1896, + "seconds": 1.400743125, + "synchronous": "FULL" + } +] \ No newline at end of file diff --git a/docs/sqlite-evidence/optimized-repeat.json b/docs/sqlite-evidence/optimized-repeat.json new file mode 100644 index 00000000..b5b690f1 --- /dev/null +++ b/docs/sqlite-evidence/optimized-repeat.json @@ -0,0 +1,1453 @@ +{ + "fixture": { + "boards": 8, + "posts": 40000, + "threads": 800 + }, + "limitations": "SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss", + "measurements": [ + { + "busy_errors": 0, + "checkout": { + "max_us": 36258, + "p50_us": 3, + "p95_us": 8066, + "p99_us": 13619 + }, + "checkout_over_100us": 1122, + "checkpoint": [ + 0, + 295, + 295 + ], + "checkpoint_us": 2191, + "cpu_seconds": 3.8600000000000003, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 37774, + "p50_us": 700, + "p95_us": 8793, + "p99_us": 14162 + }, + "operations": 3200, + "operations_per_second": 3712.021562093888, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 68016, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8606, + "p50_us": 468, + "p95_us": 1157, + "p99_us": 5126 + }, + "wal_bytes": 4614432, + "workload": "read_heavy", + "write_wait": { + "max_us": 1619, + "p50_us": 3, + "p95_us": 18, + "p99_us": 1611 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 37641, + "p50_us": 2343, + "p95_us": 11022, + "p99_us": 17506 + }, + "checkout_over_100us": 1821, + "checkpoint": [ + 0, + 72, + 72 + ], + "checkpoint_us": 603, + "cpu_seconds": 10.45, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 237764, + "p50_us": 2892, + "p95_us": 12331, + "p99_us": 24497 + }, + "operations": 3200, + "operations_per_second": 1820.5260475098087, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 71712, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9820, + "p50_us": 453, + "p95_us": 725, + "p99_us": 4209 + }, + "wal_bytes": 5018192, + "workload": "write_burst", + "write_wait": { + "max_us": 232814, + "p50_us": 3, + "p95_us": 10, + "p99_us": 24896 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 26942, + "p50_us": 3, + "p95_us": 9307, + "p99_us": 15211 + }, + "checkout_over_100us": 1331, + "checkpoint": [ + 0, + 527, + 527 + ], + "checkpoint_us": 2634, + "cpu_seconds": 5.959999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 33427, + "p50_us": 1389, + "p95_us": 10065, + "p99_us": 16483 + }, + "operations": 3200, + "operations_per_second": 2864.551472191739, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 60656, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8421, + "p50_us": 460, + "p95_us": 1053, + "p99_us": 3537 + }, + "wal_bytes": 5636192, + "workload": "mixed", + "write_wait": { + "max_us": 25973, + "p50_us": 3, + "p95_us": 1579, + "p99_us": 4591 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 16905, + "p50_us": 3, + "p95_us": 3987, + "p99_us": 7480 + }, + "checkout_over_100us": 822, + "checkpoint": [ + 0, + 797, + 797 + ], + "checkpoint_us": 3751, + "cpu_seconds": 4.25, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 26363, + "p50_us": 755, + "p95_us": 4896, + "p99_us": 9298 + }, + "operations": 3200, + "operations_per_second": 5544.51465571163, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 70528, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8949, + "p50_us": 650, + "p95_us": 1775, + "p99_us": 6275 + }, + "wal_bytes": 5496112, + "workload": "read_heavy", + "write_wait": { + "max_us": 25802, + "p50_us": 4, + "p95_us": 1626, + "p99_us": 12130 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 17998, + "p50_us": 3, + "p95_us": 6293, + "p99_us": 10388 + }, + "checkout_over_100us": 1490, + "checkpoint": [ + 0, + 158, + 158 + ], + "checkpoint_us": 1030, + "cpu_seconds": 10.54, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 506398, + "p50_us": 1523, + "p95_us": 8449, + "p99_us": 30163 + }, + "operations": 3200, + "operations_per_second": 1922.5091150933379, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 81216, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8508, + "p50_us": 453, + "p95_us": 718, + "p99_us": 4318 + }, + "wal_bytes": 5170632, + "workload": "write_burst", + "write_wait": { + "max_us": 498531, + "p50_us": 3, + "p95_us": 1560, + "p99_us": 45994 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 20924, + "p50_us": 3, + "p95_us": 5545, + "p99_us": 9324 + }, + "checkout_over_100us": 1112, + "checkpoint": [ + 0, + 908, + 908 + ], + "checkpoint_us": 4316, + "cpu_seconds": 6.049999999999997, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 227871, + "p50_us": 845, + "p95_us": 7175, + "p99_us": 13177 + }, + "operations": 3200, + "operations_per_second": 3274.4869384722156, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 71872, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8623, + "p50_us": 489, + "p95_us": 1206, + "p99_us": 3569 + }, + "wal_bytes": 8190592, + "workload": "mixed", + "write_wait": { + "max_us": 227352, + "p50_us": 4, + "p95_us": 4629, + "p99_us": 40947 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 69, + "p50_us": 2, + "p95_us": 3, + "p99_us": 10 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 1164, + 1164 + ], + "checkpoint_us": 8, + "cpu_seconds": 5.840000000000003, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 110191, + "p50_us": 864, + "p95_us": 4027, + "p99_us": 7063 + }, + "operations": 3200, + "operations_per_second": 4904.787100259105, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 106192, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12140, + "p50_us": 1783, + "p95_us": 3547, + "p99_us": 11607 + }, + "wal_bytes": 9451312, + "workload": "read_heavy", + "write_wait": { + "max_us": 108605, + "p50_us": 98, + "p95_us": 12155, + "p99_us": 78217 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 27, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 70, + 70 + ], + "checkpoint_us": 692, + "cpu_seconds": 11.119999999999997, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 502975, + "p50_us": 460, + "p95_us": 5089, + "p99_us": 102121 + }, + "operations": 3200, + "operations_per_second": 1913.9163775543961, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 112768, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8930, + "p50_us": 456, + "p95_us": 907, + "p99_us": 4061 + }, + "wal_bytes": 5562032, + "workload": "write_burst", + "write_wait": { + "max_us": 502501, + "p50_us": 3, + "p95_us": 4637, + "p99_us": 122950 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 18, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 637, + 637 + ], + "checkpoint_us": 3033, + "cpu_seconds": 6.160000000000004, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 482950, + "p50_us": 586, + "p95_us": 4941, + "p99_us": 27298 + }, + "operations": 3200, + "operations_per_second": 3458.4814270841716, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 86384, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9368, + "p50_us": 565, + "p95_us": 1393, + "p99_us": 3899 + }, + "wal_bytes": 11655512, + "workload": "mixed", + "write_wait": { + "max_us": 482497, + "p50_us": 47, + "p95_us": 23952, + "p99_us": 106602 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 51, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3460, + 3460 + ], + "checkpoint_us": 7, + "cpu_seconds": 5.670000000000002, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 29414, + "p50_us": 850, + "p95_us": 3778, + "p99_us": 7956 + }, + "operations": 3200, + "operations_per_second": 5436.963747168464, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 100672, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12489, + "p50_us": 1708, + "p95_us": 4133, + "p99_us": 11713 + }, + "wal_bytes": 14255232, + "workload": "read_heavy", + "write_wait": { + "max_us": 27098, + "p50_us": 108, + "p95_us": 22717, + "p99_us": 27079 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 26, + "p50_us": 2, + "p95_us": 3, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 524, + 524 + ], + "checkpoint_us": 3041, + "cpu_seconds": 10.530000000000001, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 922656, + "p50_us": 459, + "p95_us": 5016, + "p99_us": 73583 + }, + "operations": 3200, + "operations_per_second": 1878.475913853393, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 111424, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10755, + "p50_us": 454, + "p95_us": 930, + "p99_us": 4210 + }, + "wal_bytes": 6072912, + "workload": "write_burst", + "write_wait": { + "max_us": 921846, + "p50_us": 3, + "p95_us": 4585, + "p99_us": 110366 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 26, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 719, + 719 + ], + "checkpoint_us": 2805, + "cpu_seconds": 6.180000000000007, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 223510, + "p50_us": 626, + "p95_us": 5040, + "p99_us": 26752 + }, + "operations": 3200, + "operations_per_second": 3522.40386451734, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 76784, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8595, + "p50_us": 612, + "p95_us": 1520, + "p99_us": 3307 + }, + "wal_bytes": 18416432, + "workload": "mixed", + "write_wait": { + "max_us": 222804, + "p50_us": 47, + "p95_us": 23905, + "p99_us": 102926 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 82, + "p50_us": 2, + "p95_us": 3, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 260, + 260 + ], + "checkpoint_us": 2250, + "cpu_seconds": 6.13000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 29173, + "p50_us": 998, + "p95_us": 4198, + "p99_us": 6974 + }, + "operations": 3200, + "operations_per_second": 4956.411006039621, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 115296, + "synchronous": "NORMAL", + "transaction": { + "max_us": 13828, + "p50_us": 1957, + "p95_us": 4012, + "p99_us": 11210 + }, + "wal_bytes": 6847472, + "workload": "read_heavy", + "write_wait": { + "max_us": 27347, + "p50_us": 124, + "p95_us": 12103, + "p99_us": 26397 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 17, + "p50_us": 2, + "p95_us": 4, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 345, + 345 + ], + "checkpoint_us": 1781, + "cpu_seconds": 10.609999999999985, + "database_bytes": 15212544, + "failures": 0, + "latency": { + "max_us": 816170, + "p50_us": 478, + "p95_us": 5145, + "p99_us": 79186 + }, + "operations": 3200, + "operations_per_second": 1894.3771278058296, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 109504, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10116, + "p50_us": 473, + "p95_us": 879, + "p99_us": 4270 + }, + "wal_bytes": 5772152, + "workload": "write_burst", + "write_wait": { + "max_us": 815619, + "p50_us": 3, + "p95_us": 10352, + "p99_us": 109491 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 36, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 401, + 401 + ], + "checkpoint_us": 2279, + "cpu_seconds": 6.22999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 289201, + "p50_us": 659, + "p95_us": 5290, + "p99_us": 27657 + }, + "operations": 3200, + "operations_per_second": 3555.7652785425344, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 88048, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10594, + "p50_us": 603, + "p95_us": 1611, + "p99_us": 3347 + }, + "wal_bytes": 19755432, + "workload": "mixed", + "write_wait": { + "max_us": 288579, + "p50_us": 78, + "p95_us": 26553, + "p99_us": 77515 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 33380, + "p50_us": 3, + "p95_us": 7913, + "p99_us": 15400 + }, + "checkout_over_100us": 1115, + "checkpoint": [ + 0, + 83, + 83 + ], + "checkpoint_us": 762, + "cpu_seconds": 3.8999999999999915, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 33695, + "p50_us": 703, + "p95_us": 8691, + "p99_us": 15910 + }, + "operations": 3200, + "operations_per_second": 3597.7499995300436, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 72448, + "synchronous": "FULL", + "transaction": { + "max_us": 9589, + "p50_us": 538, + "p95_us": 1038, + "p99_us": 8914 + }, + "wal_bytes": 5125312, + "workload": "read_heavy", + "write_wait": { + "max_us": 4568, + "p50_us": 3, + "p95_us": 155, + "p99_us": 4544 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 32006, + "p50_us": 2534, + "p95_us": 10466, + "p99_us": 16527 + }, + "checkout_over_100us": 1893, + "checkpoint": [ + 0, + 527, + 527 + ], + "checkpoint_us": 1795, + "cpu_seconds": 10.489999999999995, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 499878, + "p50_us": 3097, + "p95_us": 11725, + "p99_us": 19498 + }, + "operations": 3200, + "operations_per_second": 1733.722485115773, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 77888, + "synchronous": "FULL", + "transaction": { + "max_us": 10956, + "p50_us": 490, + "p95_us": 705, + "p99_us": 3302 + }, + "wal_bytes": 5491992, + "workload": "write_burst", + "write_wait": { + "max_us": 499283, + "p50_us": 3, + "p95_us": 5, + "p99_us": 12051 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 27070, + "p50_us": 3, + "p95_us": 9076, + "p99_us": 15224 + }, + "checkout_over_100us": 1373, + "checkpoint": [ + 0, + 869, + 869 + ], + "checkpoint_us": 2616, + "cpu_seconds": 5.959999999999994, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 27139, + "p50_us": 1535, + "p95_us": 9861, + "p99_us": 16026 + }, + "operations": 3200, + "operations_per_second": 2852.773803497367, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 66528, + "synchronous": "FULL", + "transaction": { + "max_us": 8888, + "p50_us": 490, + "p95_us": 832, + "p99_us": 3889 + }, + "wal_bytes": 5994632, + "workload": "mixed", + "write_wait": { + "max_us": 12075, + "p50_us": 3, + "p95_us": 1577, + "p99_us": 4566 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 15562, + "p50_us": 2, + "p95_us": 3776, + "p99_us": 7650 + }, + "checkout_over_100us": 812, + "checkpoint": [ + 0, + 26, + 26 + ], + "checkpoint_us": 471, + "cpu_seconds": 4.159999999999997, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 17207, + "p50_us": 735, + "p95_us": 4683, + "p99_us": 9534 + }, + "operations": 3200, + "operations_per_second": 5698.886427618992, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 83520, + "synchronous": "FULL", + "transaction": { + "max_us": 9566, + "p50_us": 685, + "p95_us": 1248, + "p99_us": 9239 + }, + "wal_bytes": 8862152, + "workload": "read_heavy", + "write_wait": { + "max_us": 4575, + "p50_us": 4, + "p95_us": 1609, + "p99_us": 4568 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 21147, + "p50_us": 4, + "p95_us": 6595, + "p99_us": 10826 + }, + "checkout_over_100us": 1490, + "checkpoint": [ + 0, + 440, + 440 + ], + "checkpoint_us": 1542, + "cpu_seconds": 10.589999999999975, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 606876, + "p50_us": 1574, + "p95_us": 9078, + "p99_us": 28731 + }, + "operations": 3200, + "operations_per_second": 1782.296684082468, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 96992, + "synchronous": "FULL", + "transaction": { + "max_us": 9043, + "p50_us": 494, + "p95_us": 1050, + "p99_us": 3385 + }, + "wal_bytes": 5351912, + "workload": "write_burst", + "write_wait": { + "max_us": 606290, + "p50_us": 3, + "p95_us": 1600, + "p99_us": 45795 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 17255, + "p50_us": 3, + "p95_us": 5348, + "p99_us": 8876 + }, + "checkout_over_100us": 1108, + "checkpoint": [ + 0, + 375, + 375 + ], + "checkpoint_us": 1535, + "cpu_seconds": 6.070000000000022, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 282516, + "p50_us": 866, + "p95_us": 6731, + "p99_us": 13310 + }, + "operations": 3200, + "operations_per_second": 3214.108328507986, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 74400, + "synchronous": "FULL", + "transaction": { + "max_us": 8723, + "p50_us": 535, + "p95_us": 1196, + "p99_us": 3523 + }, + "wal_bytes": 7659112, + "workload": "mixed", + "write_wait": { + "max_us": 282024, + "p50_us": 4, + "p95_us": 4581, + "p99_us": 27081 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 2, + "p95_us": 3, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 148, + 148 + ], + "checkpoint_us": 1054, + "cpu_seconds": 5.329999999999984, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 229535, + "p50_us": 792, + "p95_us": 3365, + "p99_us": 6773 + }, + "operations": 3200, + "operations_per_second": 4853.844610790283, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 107232, + "synchronous": "FULL", + "transaction": { + "max_us": 11021, + "p50_us": 1489, + "p95_us": 3003, + "p99_us": 10506 + }, + "wal_bytes": 13645472, + "workload": "read_heavy", + "write_wait": { + "max_us": 228506, + "p50_us": 8, + "p95_us": 12220, + "p99_us": 43131 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 54, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 209, + 209 + ], + "checkpoint_us": 1173, + "cpu_seconds": 10.53000000000003, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 908597, + "p50_us": 497, + "p95_us": 3803, + "p99_us": 50449 + }, + "operations": 3200, + "operations_per_second": 1877.147903757787, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 112528, + "synchronous": "FULL", + "transaction": { + "max_us": 9137, + "p50_us": 494, + "p95_us": 779, + "p99_us": 3436 + }, + "wal_bytes": 5277752, + "workload": "write_burst", + "write_wait": { + "max_us": 907953, + "p50_us": 3, + "p95_us": 4560, + "p99_us": 112124 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 35, + "p50_us": 3, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 638, + 638 + ], + "checkpoint_us": 3806, + "cpu_seconds": 6.219999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 581880, + "p50_us": 594, + "p95_us": 4986, + "p99_us": 38429 + }, + "operations": 3200, + "operations_per_second": 3027.3250397676275, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 87104, + "synchronous": "FULL", + "transaction": { + "max_us": 10741, + "p50_us": 590, + "p95_us": 1481, + "p99_us": 3542 + }, + "wal_bytes": 11663752, + "workload": "mixed", + "write_wait": { + "max_us": 580893, + "p50_us": 56, + "p95_us": 24758, + "p99_us": 98586 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 49, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 49, + 49 + ], + "checkpoint_us": 810, + "cpu_seconds": 5.72999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 87386, + "p50_us": 852, + "p95_us": 3678, + "p99_us": 7166 + }, + "operations": 3200, + "operations_per_second": 5255.176457198355, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 114448, + "synchronous": "FULL", + "transaction": { + "max_us": 10700, + "p50_us": 1769, + "p95_us": 3669, + "p99_us": 10254 + }, + "wal_bytes": 14053352, + "workload": "read_heavy", + "write_wait": { + "max_us": 77128, + "p50_us": 120, + "p95_us": 12100, + "p99_us": 49136 + } + }, + { + "busy_errors": 1, + "checkout": { + "max_us": 28, + "p50_us": 2, + "p95_us": 4, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 587, + 587 + ], + "checkpoint_us": 2166, + "cpu_seconds": 10.54000000000002, + "database_bytes": 15196160, + "failures": 1, + "latency": { + "max_us": 1113361, + "p50_us": 506, + "p95_us": 3401, + "p99_us": 40005 + }, + "operations": 3200, + "operations_per_second": 1826.505396726793, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 98800, + "synchronous": "FULL", + "transaction": { + "max_us": 9757, + "p50_us": 500, + "p95_us": 779, + "p99_us": 3606 + }, + "wal_bytes": 5520832, + "workload": "write_burst", + "write_wait": { + "max_us": 1110626, + "p50_us": 3, + "p95_us": 4541, + "p99_us": 76621 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 20, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 277, + 277 + ], + "checkpoint_us": 1070, + "cpu_seconds": 6.069999999999993, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 387484, + "p50_us": 535, + "p95_us": 3271, + "p99_us": 27571 + }, + "operations": 3200, + "operations_per_second": 3190.7448457318487, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 72784, + "synchronous": "FULL", + "transaction": { + "max_us": 8494, + "p50_us": 539, + "p95_us": 1154, + "p99_us": 3623 + }, + "wal_bytes": 8478992, + "workload": "mixed", + "write_wait": { + "max_us": 386899, + "p50_us": 42, + "p95_us": 24681, + "p99_us": 107559 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 46, + "p50_us": 2, + "p95_us": 3, + "p99_us": 12 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 911, + 911 + ], + "checkpoint_us": 3452, + "cpu_seconds": 5.909999999999997, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 49369, + "p50_us": 906, + "p95_us": 4108, + "p99_us": 7264 + }, + "operations": 3200, + "operations_per_second": 5064.045008877556, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 110096, + "synchronous": "FULL", + "transaction": { + "max_us": 12503, + "p50_us": 1828, + "p95_us": 4211, + "p99_us": 11452 + }, + "wal_bytes": 10493672, + "workload": "read_heavy", + "write_wait": { + "max_us": 47792, + "p50_us": 98, + "p95_us": 12110, + "p99_us": 42915 + } + }, + { + "busy_errors": 1, + "checkout": { + "max_us": 13, + "p50_us": 3, + "p95_us": 4, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 266, + 266 + ], + "checkpoint_us": 1156, + "cpu_seconds": 10.5, + "database_bytes": 15216640, + "failures": 1, + "latency": { + "max_us": 1113083, + "p50_us": 511, + "p95_us": 4963, + "p99_us": 67227 + }, + "operations": 3200, + "operations_per_second": 1826.623291769334, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 100256, + "synchronous": "FULL", + "transaction": { + "max_us": 9157, + "p50_us": 504, + "p95_us": 766, + "p99_us": 3680 + }, + "wal_bytes": 5141792, + "workload": "write_burst", + "write_wait": { + "max_us": 1113075, + "p50_us": 3, + "p95_us": 4572, + "p99_us": 78251 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 102, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 1, + "checkpoint": [ + 0, + 385, + 385 + ], + "checkpoint_us": 1479, + "cpu_seconds": 6.150000000000006, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 224127, + "p50_us": 636, + "p95_us": 5189, + "p99_us": 44766 + }, + "operations": 3200, + "operations_per_second": 3400.7254095873004, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 70384, + "synchronous": "FULL", + "transaction": { + "max_us": 8665, + "p50_us": 587, + "p95_us": 1299, + "p99_us": 3311 + }, + "wal_bytes": 11816192, + "workload": "mixed", + "write_wait": { + "max_us": 223564, + "p50_us": 74, + "p95_us": 27083, + "p99_us": 106299 + } + } + ], + "query_plans": [ + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "board_index" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "name": "live_poll" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "name": "cooldown" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "latest_thread" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)" + ], + "name": "prune" + }, + { + "details": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "search_selective" + }, + { + "details": [ + "SEARCH reports USING INDEX idx_reports_status (status=?)" + ], + "name": "reports" + }, + { + "details": [ + "SEARCH bans USING INDEX idx_bans_ip (ip_hash=?)" + ], + "name": "bans" + }, + { + "details": [ + "SEARCH background_jobs USING INDEX idx_jobs_pending (status=?)" + ], + "name": "job_claim" + }, + { + "details": [ + "MULTI-INDEX OR", + "INDEX 1", + "SEARCH posts USING INDEX idx_posts_file_path (file_path=?)", + "INDEX 2", + "SEARCH posts USING INDEX idx_posts_thumb_path (thumb_path=?)", + "INDEX 3", + "SEARCH posts USING INDEX idx_posts_audio_file_path (audio_file_path=?)" + ], + "name": "file_refs" + }, + { + "details": [ + "SEARCH file_hashes USING INDEX sqlite_autoindex_file_hashes_1 (sha256=?)" + ], + "name": "dedup" + }, + { + "details": [ + "SCAN mod_log USING INDEX idx_mod_log_created" + ], + "name": "mod_log" + } + ], + "sqlite_version": "3.51.3", + "workers": 8 +} \ No newline at end of file diff --git a/docs/sqlite-evidence/optimized.json b/docs/sqlite-evidence/optimized.json new file mode 100644 index 00000000..c1ec5be4 --- /dev/null +++ b/docs/sqlite-evidence/optimized.json @@ -0,0 +1,1453 @@ +{ + "fixture": { + "boards": 8, + "posts": 40000, + "threads": 800 + }, + "limitations": "SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss", + "measurements": [ + { + "busy_errors": 0, + "checkout": { + "max_us": 32531, + "p50_us": 3, + "p95_us": 7725, + "p99_us": 15506 + }, + "checkout_over_100us": 1181, + "checkpoint": [ + 0, + 89, + 89 + ], + "checkpoint_us": 1108, + "cpu_seconds": 3.9400000000000004, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 32757, + "p50_us": 744, + "p95_us": 8569, + "p99_us": 16495 + }, + "operations": 3200, + "operations_per_second": 3455.361121005532, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 68592, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9085, + "p50_us": 556, + "p95_us": 1667, + "p99_us": 9060 + }, + "wal_bytes": 4935792, + "workload": "read_heavy", + "write_wait": { + "max_us": 12155, + "p50_us": 4, + "p95_us": 1537, + "p99_us": 1649 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 35486, + "p50_us": 2239, + "p95_us": 10715, + "p99_us": 16648 + }, + "checkout_over_100us": 1779, + "checkpoint": [ + 0, + 610, + 610 + ], + "checkpoint_us": 2445, + "cpu_seconds": 10.37, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 131181, + "p50_us": 2755, + "p95_us": 12070, + "p99_us": 22652 + }, + "operations": 3200, + "operations_per_second": 1927.907844197634, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 70768, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10356, + "p50_us": 440, + "p95_us": 671, + "p99_us": 4254 + }, + "wal_bytes": 5393112, + "workload": "write_burst", + "write_wait": { + "max_us": 130577, + "p50_us": 4, + "p95_us": 40, + "p99_us": 12098 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 33033, + "p50_us": 3, + "p95_us": 9706, + "p99_us": 15775 + }, + "checkout_over_100us": 1335, + "checkpoint": [ + 0, + 1008, + 1008 + ], + "checkpoint_us": 8, + "cpu_seconds": 5.970000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 48149, + "p50_us": 1536, + "p95_us": 10459, + "p99_us": 17289 + }, + "operations": 3200, + "operations_per_second": 2687.5391392833735, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 61360, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8601, + "p50_us": 476, + "p95_us": 1455, + "p99_us": 4398 + }, + "wal_bytes": 5990512, + "workload": "mixed", + "write_wait": { + "max_us": 47562, + "p50_us": 4, + "p95_us": 1593, + "p99_us": 4589 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 25528, + "p50_us": 3, + "p95_us": 3948, + "p99_us": 7223 + }, + "checkout_over_100us": 843, + "checkpoint": [ + 0, + 2202, + 2202 + ], + "checkpoint_us": 2517, + "cpu_seconds": 4.199999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 27327, + "p50_us": 752, + "p95_us": 4942, + "p99_us": 8803 + }, + "operations": 3200, + "operations_per_second": 5686.233200643719, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 84480, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9466, + "p50_us": 680, + "p95_us": 1721, + "p99_us": 7812 + }, + "wal_bytes": 9072272, + "workload": "read_heavy", + "write_wait": { + "max_us": 12137, + "p50_us": 4, + "p95_us": 1675, + "p99_us": 12102 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 23168, + "p50_us": 715, + "p95_us": 6582, + "p99_us": 9974 + }, + "checkout_over_100us": 1662, + "checkpoint": [ + 0, + 682, + 682 + ], + "checkpoint_us": 2883, + "cpu_seconds": 10.440000000000001, + "database_bytes": 15187968, + "failures": 0, + "latency": { + "max_us": 305324, + "p50_us": 1783, + "p95_us": 8808, + "p99_us": 47643 + }, + "operations": 3200, + "operations_per_second": 1883.9719568584105, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 85952, + "synchronous": "NORMAL", + "transaction": { + "max_us": 9214, + "p50_us": 463, + "p95_us": 818, + "p99_us": 4203 + }, + "wal_bytes": 5104712, + "workload": "write_burst", + "write_wait": { + "max_us": 297979, + "p50_us": 3, + "p95_us": 4552, + "p99_us": 71019 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 18313, + "p50_us": 3, + "p95_us": 5226, + "p99_us": 9303 + }, + "checkout_over_100us": 1208, + "checkpoint": [ + 0, + 805, + 805 + ], + "checkpoint_us": 3163, + "cpu_seconds": 6.109999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 101874, + "p50_us": 1020, + "p95_us": 7089, + "p99_us": 14240 + }, + "operations": 3200, + "operations_per_second": 3380.9449339180474, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 67952, + "synchronous": "NORMAL", + "transaction": { + "max_us": 8339, + "p50_us": 522, + "p95_us": 1362, + "p99_us": 3359 + }, + "wal_bytes": 8034032, + "workload": "mixed", + "write_wait": { + "max_us": 101363, + "p50_us": 30, + "p95_us": 11134, + "p99_us": 26920 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 78, + "p50_us": 2, + "p95_us": 3, + "p99_us": 11 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 3460, + 3460 + ], + "checkpoint_us": 7, + "cpu_seconds": 5.780000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 76034, + "p50_us": 859, + "p95_us": 4032, + "p99_us": 7040 + }, + "operations": 3200, + "operations_per_second": 4953.215969656797, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 91072, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12965, + "p50_us": 1974, + "p95_us": 3619, + "p99_us": 9623 + }, + "wal_bytes": 14255232, + "workload": "read_heavy", + "write_wait": { + "max_us": 73519, + "p50_us": 114, + "p95_us": 12218, + "p99_us": 49796 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 14, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 701, + 701 + ], + "checkpoint_us": 3180, + "cpu_seconds": 10.46, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 1044923, + "p50_us": 475, + "p95_us": 4846, + "p99_us": 77271 + }, + "operations": 3200, + "operations_per_second": 1863.0262813309948, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 115584, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10351, + "p50_us": 471, + "p95_us": 823, + "p99_us": 4656 + }, + "wal_bytes": 5479632, + "workload": "write_burst", + "write_wait": { + "max_us": 1044004, + "p50_us": 3, + "p95_us": 4597, + "p99_us": 105968 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 82, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 181, + 181 + ], + "checkpoint_us": 1217, + "cpu_seconds": 6.130000000000003, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 406916, + "p50_us": 579, + "p95_us": 4601, + "p99_us": 41080 + }, + "operations": 3200, + "operations_per_second": 3246.995249367926, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 75168, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10342, + "p50_us": 589, + "p95_us": 1570, + "p99_us": 3831 + }, + "wal_bytes": 14045112, + "workload": "mixed", + "write_wait": { + "max_us": 406281, + "p50_us": 40, + "p95_us": 25595, + "p99_us": 103449 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 90, + "p50_us": 2, + "p95_us": 4, + "p99_us": 14 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 927, + 927 + ], + "checkpoint_us": 5164, + "cpu_seconds": 6.030000000000001, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 115074, + "p50_us": 888, + "p95_us": 4224, + "p99_us": 7124 + }, + "operations": 3200, + "operations_per_second": 4681.820035592806, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 96848, + "synchronous": "NORMAL", + "transaction": { + "max_us": 13284, + "p50_us": 1993, + "p95_us": 4518, + "p99_us": 10837 + }, + "wal_bytes": 10419512, + "workload": "read_heavy", + "write_wait": { + "max_us": 112876, + "p50_us": 80, + "p95_us": 12069, + "p99_us": 48656 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 155, + "p50_us": 3, + "p95_us": 8, + "p99_us": 31 + }, + "checkout_over_100us": 2, + "checkpoint": [ + 0, + 938, + 938 + ], + "checkpoint_us": 3634, + "cpu_seconds": 11.440000000000012, + "database_bytes": 15196160, + "failures": 0, + "latency": { + "max_us": 796608, + "p50_us": 589, + "p95_us": 6630, + "p99_us": 123034 + }, + "operations": 3200, + "operations_per_second": 1405.7358304985698, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 87920, + "synchronous": "NORMAL", + "transaction": { + "max_us": 17679, + "p50_us": 575, + "p95_us": 2009, + "p99_us": 5203 + }, + "wal_bytes": 5590872, + "workload": "write_burst", + "write_wait": { + "max_us": 795282, + "p50_us": 3, + "p95_us": 11982, + "p99_us": 148757 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 55, + "p50_us": 3, + "p95_us": 4, + "p99_us": 6 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 279, + 279 + ], + "checkpoint_us": 1629, + "cpu_seconds": 6.310000000000002, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 476162, + "p50_us": 630, + "p95_us": 4228, + "p99_us": 24757 + }, + "operations": 3200, + "operations_per_second": 3004.391693969342, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 70096, + "synchronous": "NORMAL", + "transaction": { + "max_us": 10130, + "p50_us": 630, + "p95_us": 1840, + "p99_us": 4752 + }, + "wal_bytes": 12945072, + "workload": "mixed", + "write_wait": { + "max_us": 475350, + "p50_us": 54, + "p95_us": 21012, + "p99_us": 102674 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 81, + "p50_us": 2, + "p95_us": 4, + "p99_us": 17 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 1455, + 1455 + ], + "checkpoint_us": 9, + "cpu_seconds": 6.0800000000000125, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 148422, + "p50_us": 905, + "p95_us": 4668, + "p99_us": 7439 + }, + "operations": 3200, + "operations_per_second": 4310.519013429961, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 73696, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12534, + "p50_us": 2072, + "p95_us": 4128, + "p99_us": 7628 + }, + "wal_bytes": 8260632, + "workload": "read_heavy", + "write_wait": { + "max_us": 146004, + "p50_us": 145, + "p95_us": 12194, + "p99_us": 130275 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 52, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 42, + 42 + ], + "checkpoint_us": 568, + "cpu_seconds": 10.460000000000008, + "database_bytes": 15192064, + "failures": 0, + "latency": { + "max_us": 1026774, + "p50_us": 455, + "p95_us": 5030, + "p99_us": 53078 + }, + "operations": 3200, + "operations_per_second": 1839.1869085259677, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 102608, + "synchronous": "NORMAL", + "transaction": { + "max_us": 11093, + "p50_us": 450, + "p95_us": 816, + "p99_us": 4074 + }, + "wal_bytes": 5117072, + "workload": "write_burst", + "write_wait": { + "max_us": 1025964, + "p50_us": 3, + "p95_us": 4594, + "p99_us": 101544 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 43, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 388, + 388 + ], + "checkpoint_us": 2018, + "cpu_seconds": 6.170000000000002, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 382063, + "p50_us": 635, + "p95_us": 5158, + "p99_us": 27214 + }, + "operations": 3200, + "operations_per_second": 3380.292254744385, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 76272, + "synchronous": "NORMAL", + "transaction": { + "max_us": 12267, + "p50_us": 609, + "p95_us": 1704, + "p99_us": 3293 + }, + "wal_bytes": 18152752, + "workload": "mixed", + "write_wait": { + "max_us": 381350, + "p50_us": 62, + "p95_us": 24983, + "p99_us": 95121 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 35880, + "p50_us": 2, + "p95_us": 7914, + "p99_us": 14466 + }, + "checkout_over_100us": 1100, + "checkpoint": [ + 0, + 69, + 69 + ], + "checkpoint_us": 752, + "cpu_seconds": 3.8400000000000034, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 35949, + "p50_us": 689, + "p95_us": 8585, + "p99_us": 15358 + }, + "operations": 3200, + "operations_per_second": 3677.993224187099, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 56640, + "synchronous": "FULL", + "transaction": { + "max_us": 8575, + "p50_us": 531, + "p95_us": 1095, + "p99_us": 5046 + }, + "wal_bytes": 5356032, + "workload": "read_heavy", + "write_wait": { + "max_us": 12093, + "p50_us": 3, + "p95_us": 1328, + "p99_us": 1595 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 29494, + "p50_us": 2534, + "p95_us": 10415, + "p99_us": 15716 + }, + "checkout_over_100us": 1904, + "checkpoint": [ + 0, + 257, + 257 + ], + "checkpoint_us": 1173, + "cpu_seconds": 10.560000000000002, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 179932, + "p50_us": 3090, + "p95_us": 11987, + "p99_us": 19831 + }, + "operations": 3200, + "operations_per_second": 1809.933679940648, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 51856, + "synchronous": "FULL", + "transaction": { + "max_us": 8397, + "p50_us": 491, + "p95_us": 717, + "p99_us": 3367 + }, + "wal_bytes": 4659752, + "workload": "write_burst", + "write_wait": { + "max_us": 179182, + "p50_us": 3, + "p95_us": 63, + "p99_us": 12110 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 31358, + "p50_us": 3, + "p95_us": 10103, + "p99_us": 16817 + }, + "checkout_over_100us": 1392, + "checkpoint": [ + 0, + 830, + 830 + ], + "checkpoint_us": 3094, + "cpu_seconds": 6.019999999999996, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 31996, + "p50_us": 1591, + "p95_us": 11377, + "p99_us": 18086 + }, + "operations": 3200, + "operations_per_second": 2633.843580662017, + "pool_size": 2, + "pool_timeouts": 0, + "rss_kib": 43408, + "synchronous": "FULL", + "transaction": { + "max_us": 8645, + "p50_us": 513, + "p95_us": 1259, + "p99_us": 3770 + }, + "wal_bytes": 5673272, + "workload": "mixed", + "write_wait": { + "max_us": 26704, + "p50_us": 3, + "p95_us": 1619, + "p99_us": 9685 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 28274, + "p50_us": 3, + "p95_us": 3801, + "p99_us": 7475 + }, + "checkout_over_100us": 838, + "checkpoint": [ + 0, + 1644, + 1644 + ], + "checkpoint_us": 2432, + "cpu_seconds": 4.199999999999989, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 28777, + "p50_us": 742, + "p95_us": 4853, + "p99_us": 8968 + }, + "operations": 3200, + "operations_per_second": 5656.393956441432, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 57168, + "synchronous": "FULL", + "transaction": { + "max_us": 8960, + "p50_us": 700, + "p95_us": 1690, + "p99_us": 8485 + }, + "wal_bytes": 7481952, + "workload": "read_heavy", + "write_wait": { + "max_us": 12170, + "p50_us": 3, + "p95_us": 1684, + "p99_us": 12072 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 21491, + "p50_us": 1613, + "p95_us": 6846, + "p99_us": 10549 + }, + "checkout_over_100us": 1869, + "checkpoint": [ + 0, + 647, + 647 + ], + "checkpoint_us": 1760, + "cpu_seconds": 10.539999999999992, + "database_bytes": 15200256, + "failures": 0, + "latency": { + "max_us": 401966, + "p50_us": 2200, + "p95_us": 9610, + "p99_us": 45851 + }, + "operations": 3200, + "operations_per_second": 1730.7645248371298, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 68528, + "synchronous": "FULL", + "transaction": { + "max_us": 8953, + "p50_us": 506, + "p95_us": 750, + "p99_us": 3550 + }, + "wal_bytes": 4882232, + "workload": "write_burst", + "write_wait": { + "max_us": 397701, + "p50_us": 3, + "p95_us": 3728, + "p99_us": 62936 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 18625, + "p50_us": 3, + "p95_us": 5439, + "p99_us": 9668 + }, + "checkout_over_100us": 1194, + "checkpoint": [ + 0, + 584, + 584 + ], + "checkpoint_us": 1844, + "cpu_seconds": 7.3300000000000125, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 151279, + "p50_us": 968, + "p95_us": 7566, + "p99_us": 17594 + }, + "operations": 3200, + "operations_per_second": 3018.384199984932, + "pool_size": 4, + "pool_timeouts": 0, + "rss_kib": 51344, + "synchronous": "FULL", + "transaction": { + "max_us": 10402, + "p50_us": 550, + "p95_us": 1163, + "p99_us": 3635 + }, + "wal_bytes": 7292432, + "workload": "mixed", + "write_wait": { + "max_us": 143932, + "p50_us": 5, + "p95_us": 12047, + "p99_us": 43980 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 4301, + "p50_us": 4, + "p95_us": 16, + "p99_us": 34 + }, + "checkout_over_100us": 12, + "checkpoint": [ + 0, + 3460, + 3460 + ], + "checkpoint_us": 35, + "cpu_seconds": 6.219999999999999, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 131627, + "p50_us": 1196, + "p95_us": 11074, + "p99_us": 30020 + }, + "operations": 3200, + "operations_per_second": 2495.9066156539757, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 83632, + "synchronous": "FULL", + "transaction": { + "max_us": 30673, + "p50_us": 2454, + "p95_us": 12382, + "p99_us": 23512 + }, + "wal_bytes": 14255232, + "workload": "read_heavy", + "write_wait": { + "max_us": 129314, + "p50_us": 57, + "p95_us": 40725, + "p99_us": 74943 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 59, + "p50_us": 3, + "p95_us": 4, + "p99_us": 9 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 707, + 707 + ], + "checkpoint_us": 2099, + "cpu_seconds": 10.610000000000014, + "database_bytes": 15204352, + "failures": 0, + "latency": { + "max_us": 716857, + "p50_us": 529, + "p95_us": 5127, + "p99_us": 80081 + }, + "operations": 3200, + "operations_per_second": 1724.5564872123475, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 72784, + "synchronous": "FULL", + "transaction": { + "max_us": 11383, + "p50_us": 524, + "p95_us": 1121, + "p99_us": 3818 + }, + "wal_bytes": 5780392, + "workload": "write_burst", + "write_wait": { + "max_us": 716266, + "p50_us": 3, + "p95_us": 4628, + "p99_us": 130775 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 36, + "p50_us": 2, + "p95_us": 3, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 820, + 820 + ], + "checkpoint_us": 2554, + "cpu_seconds": 6.140000000000015, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 385533, + "p50_us": 567, + "p95_us": 5103, + "p99_us": 27757 + }, + "operations": 3200, + "operations_per_second": 3163.647056835317, + "pool_size": 8, + "pool_timeouts": 0, + "rss_kib": 61024, + "synchronous": "FULL", + "transaction": { + "max_us": 8641, + "p50_us": 555, + "p95_us": 1161, + "p99_us": 3377 + }, + "wal_bytes": 8198832, + "workload": "mixed", + "write_wait": { + "max_us": 384641, + "p50_us": 57, + "p95_us": 26188, + "p99_us": 113147 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 68, + "p50_us": 2, + "p95_us": 3, + "p99_us": 14 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 2267, + 2267 + ], + "checkpoint_us": 1337, + "cpu_seconds": 5.789999999999992, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 102243, + "p50_us": 876, + "p95_us": 4018, + "p99_us": 7157 + }, + "operations": 3200, + "operations_per_second": 5091.736455266197, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 68896, + "synchronous": "FULL", + "transaction": { + "max_us": 13715, + "p50_us": 1832, + "p95_us": 3679, + "p99_us": 7495 + }, + "wal_bytes": 9340072, + "workload": "read_heavy", + "write_wait": { + "max_us": 100333, + "p50_us": 85, + "p95_us": 12125, + "p99_us": 48728 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 16, + "p50_us": 3, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 615, + 615 + ], + "checkpoint_us": 1831, + "cpu_seconds": 10.400000000000006, + "database_bytes": 15208448, + "failures": 0, + "latency": { + "max_us": 828124, + "p50_us": 496, + "p95_us": 3737, + "p99_us": 50032 + }, + "operations": 3200, + "operations_per_second": 1816.1688117559472, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 102608, + "synchronous": "FULL", + "transaction": { + "max_us": 8846, + "p50_us": 492, + "p95_us": 725, + "p99_us": 3348 + }, + "wal_bytes": 4890472, + "workload": "write_burst", + "write_wait": { + "max_us": 827564, + "p50_us": 3, + "p95_us": 4582, + "p99_us": 104970 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 58, + "p50_us": 3, + "p95_us": 4, + "p99_us": 5 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 276, + 276 + ], + "checkpoint_us": 1768, + "cpu_seconds": 6.139999999999986, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 504422, + "p50_us": 589, + "p95_us": 5059, + "p99_us": 24492 + }, + "operations": 3200, + "operations_per_second": 3029.9143663161485, + "pool_size": 12, + "pool_timeouts": 0, + "rss_kib": 63136, + "synchronous": "FULL", + "transaction": { + "max_us": 8743, + "p50_us": 579, + "p95_us": 1250, + "p99_us": 3564 + }, + "wal_bytes": 10295912, + "workload": "mixed", + "write_wait": { + "max_us": 503871, + "p50_us": 59, + "p95_us": 12139, + "p99_us": 142615 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 49, + "p50_us": 2, + "p95_us": 3, + "p99_us": 12 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 48, + 48 + ], + "checkpoint_us": 688, + "cpu_seconds": 5.6200000000000045, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 81503, + "p50_us": 811, + "p95_us": 3936, + "p99_us": 7202 + }, + "operations": 3200, + "operations_per_second": 5106.928982775856, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 102544, + "synchronous": "FULL", + "transaction": { + "max_us": 13323, + "p50_us": 1735, + "p95_us": 4812, + "p99_us": 9702 + }, + "wal_bytes": 14057472, + "workload": "read_heavy", + "write_wait": { + "max_us": 79225, + "p50_us": 85, + "p95_us": 23820, + "p99_us": 49968 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 30, + "p50_us": 3, + "p95_us": 4, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 69, + 69 + ], + "checkpoint_us": 516, + "cpu_seconds": 10.469999999999999, + "database_bytes": 15192064, + "failures": 0, + "latency": { + "max_us": 1030743, + "p50_us": 508, + "p95_us": 5066, + "p99_us": 75838 + }, + "operations": 3200, + "operations_per_second": 1835.341084160354, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 97504, + "synchronous": "FULL", + "transaction": { + "max_us": 10341, + "p50_us": 502, + "p95_us": 771, + "p99_us": 3460 + }, + "wal_bytes": 5471392, + "workload": "write_burst", + "write_wait": { + "max_us": 1029972, + "p50_us": 3, + "p95_us": 4595, + "p99_us": 102290 + } + }, + { + "busy_errors": 0, + "checkout": { + "max_us": 37, + "p50_us": 2, + "p95_us": 3, + "p99_us": 4 + }, + "checkout_over_100us": 0, + "checkpoint": [ + 0, + 874, + 874 + ], + "checkpoint_us": 2532, + "cpu_seconds": 6.060000000000002, + "database_bytes": 14753792, + "failures": 0, + "latency": { + "max_us": 718727, + "p50_us": 556, + "p95_us": 5026, + "p99_us": 26297 + }, + "operations": 3200, + "operations_per_second": 3170.9820766540442, + "pool_size": 16, + "pool_timeouts": 0, + "rss_kib": 68112, + "synchronous": "FULL", + "transaction": { + "max_us": 8783, + "p50_us": 556, + "p95_us": 1162, + "p99_us": 3652 + }, + "wal_bytes": 9859192, + "workload": "mixed", + "write_wait": { + "max_us": 718130, + "p50_us": 44, + "p95_us": 12928, + "p99_us": 103761 + } + } + ], + "query_plans": [ + { + "details": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "name": "board_index" + }, + { + "details": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "name": "live_poll" + }, + { + "details": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "name": "cooldown" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "latest_thread" + }, + { + "details": [ + "SEARCH threads USING INDEX idx_threads_archived (board_id=? AND archived=?)" + ], + "name": "prune" + }, + { + "details": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "name": "search_selective" + }, + { + "details": [ + "SEARCH reports USING INDEX idx_reports_status (status=?)" + ], + "name": "reports" + }, + { + "details": [ + "SEARCH bans USING INDEX idx_bans_ip (ip_hash=?)" + ], + "name": "bans" + }, + { + "details": [ + "SEARCH background_jobs USING INDEX idx_jobs_pending (status=?)" + ], + "name": "job_claim" + }, + { + "details": [ + "MULTI-INDEX OR", + "INDEX 1", + "SEARCH posts USING INDEX idx_posts_file_path (file_path=?)", + "INDEX 2", + "SEARCH posts USING INDEX idx_posts_thumb_path (thumb_path=?)", + "INDEX 3", + "SEARCH posts USING INDEX idx_posts_audio_file_path (audio_file_path=?)" + ], + "name": "file_refs" + }, + { + "details": [ + "SEARCH file_hashes USING INDEX sqlite_autoindex_file_hashes_1 (sha256=?)" + ], + "name": "dedup" + }, + { + "details": [ + "SCAN mod_log USING INDEX idx_mod_log_created" + ], + "name": "mod_log" + } + ], + "sqlite_version": "3.51.3", + "workers": 8 +} \ No newline at end of file diff --git a/docs/sqlite-evidence/query-profile.json b/docs/sqlite-evidence/query-profile.json new file mode 100644 index 00000000..ecd9f063 --- /dev/null +++ b/docs/sqlite-evidence/query-profile.json @@ -0,0 +1,286 @@ +{ + "database_bytes_after": 13512704, + "database_bytes_before": 12210176, + "queries": [ + { + "after": { + "max_us": 640, + "p50_us": 287, + "p95_us": 341, + "p99_us": 640 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 74905, + "p50_us": 74295, + "p95_us": 74806, + "p99_us": 74905 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_needle" + }, + { + "after": { + "max_us": 409, + "p50_us": 258, + "p95_us": 323, + "p99_us": 409 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 159648, + "p50_us": 157500, + "p95_us": 158784, + "p99_us": 159648 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_needle" + }, + { + "after": { + "max_us": 11181, + "p50_us": 10528, + "p95_us": 10724, + "p99_us": 11181 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 57525, + "p50_us": 56581, + "p95_us": 57194, + "p99_us": 57525 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_representative" + }, + { + "after": { + "max_us": 9061, + "p50_us": 8623, + "p95_us": 8952, + "p99_us": 9061 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 19234274, + "p50_us": 11241770, + "p95_us": 11777815, + "p99_us": 19234274 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_representative" + }, + { + "after": { + "max_us": 22, + "p50_us": 12, + "p95_us": 16, + "p99_us": 22 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "before": { + "max_us": 39554, + "p50_us": 38995, + "p95_us": 39379, + "p99_us": 39554 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" + ], + "query": "fts_p.id_missingword" + }, + { + "after": { + "max_us": 16, + "p50_us": 10, + "p95_us": 11, + "p99_us": 16 + }, + "after_plan": [ + "SCAN posts_fts VIRTUAL TABLE INDEX 0:M1", + "SEARCH p USING INTEGER PRIMARY KEY (rowid=?)" + ], + "before": { + "max_us": 39571, + "p50_us": 38762, + "p95_us": 39389, + "p99_us": 39571 + }, + "before_plan": [ + "SEARCH p USING COVERING INDEX idx_posts_board (board_id=?)", + "SCAN f VIRTUAL TABLE INDEX 0:=M1" + ], + "query": "fts_COUNT(*)_missingword" + }, + { + "after": { + "max_us": 1138, + "p50_us": 434, + "p95_us": 524, + "p99_us": 1138 + }, + "before": { + "max_us": 119853, + "p50_us": 74174, + "p95_us": 75448, + "p99_us": 119853 + }, + "query": "production_fts_needle" + }, + { + "after": { + "max_us": 13872, + "p50_us": 13300, + "p95_us": 13671, + "p99_us": 13872 + }, + "before": { + "max_us": 57792, + "p50_us": 56798, + "p95_us": 57438, + "p99_us": 57792 + }, + "query": "production_fts_representative" + }, + { + "after": { + "max_us": 169, + "p50_us": 76, + "p95_us": 91, + "p99_us": 169 + }, + "before": { + "max_us": 39664, + "p50_us": 38956, + "p95_us": 39613, + "p99_us": 39664 + }, + "query": "production_fts_missingword" + }, + { + "after": { + "max_us": 282, + "p50_us": 144, + "p95_us": 156, + "p99_us": 282 + }, + "after_plan": [ + "MATERIALIZE page", + "SEARCH t USING INDEX idx_threads_board_sticky_bumped (board_id=?)", + "SEARCH op EXISTS USING COVERING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "USE TEMP B-TREE FOR LAST TERM OF ORDER BY", + "SCAN t", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 3", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?)", + "CORRELATED SCALAR SUBQUERY 4", + "SEARCH fp USING COVERING INDEX idx_posts_thread (thread_id=?)" + ], + "before": { + "max_us": 6398, + "p50_us": 5372, + "p95_us": 5538, + "p99_us": 6398 + }, + "before_plan": [ + "SEARCH t USING INDEX idx_threads_archived (board_id=? AND archived=?)", + "SEARCH op USING INDEX idx_posts_one_op_per_thread (thread_id=?)", + "SEARCH fp USING INDEX idx_posts_thread_live (thread_id=?) LEFT-JOIN", + "USE TEMP B-TREE FOR GROUP BY", + "USE TEMP B-TREE FOR count(DISTINCT)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "query": "board_page" + }, + { + "after": { + "max_us": 46, + "p50_us": 5, + "p95_us": 6, + "p99_us": 46 + }, + "after_plan": [ + "SEARCH posts USING INDEX idx_posts_thread_live (thread_id=? AND id>?)" + ], + "before": { + "max_us": 2945, + "p50_us": 2749, + "p95_us": 2847, + "p99_us": 2945 + }, + "before_plan": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)", + "USE TEMP B-TREE FOR ORDER BY" + ], + "query": "live_poll" + }, + { + "after": { + "max_us": 24, + "p50_us": 2, + "p95_us": 3, + "p99_us": 24 + }, + "after_plan": [ + "SEARCH posts USING COVERING INDEX idx_posts_board_ip_created (board_id=? AND ip_hash=?)" + ], + "before": { + "max_us": 170, + "p50_us": 154, + "p95_us": 167, + "p99_us": 170 + }, + "before_plan": [ + "SEARCH posts USING INDEX idx_posts_board (board_id=?)" + ], + "query": "cooldown" + } + ], + "sqlite": "3.51.3", + "write_after": { + "max_us": 2480, + "p50_us": 404, + "p95_us": 543, + "p99_us": 1362 + }, + "write_before": { + "max_us": 7782, + "p50_us": 384, + "p95_us": 510, + "p99_us": 1568 + } +} \ No newline at end of file diff --git a/src/db/diagnostics.rs b/src/db/diagnostics.rs new file mode 100644 index 00000000..d87f2452 --- /dev/null +++ b/src/db/diagnostics.rs @@ -0,0 +1,146 @@ +//! Timing-only diagnostics; never record SQL parameters or private content. + +use anyhow::{Context as _, Result}; +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant}, +}; + +/// Slow operation threshold; normal checkouts and short writes stay quiet. +const SLOW: Duration = Duration::from_millis(250); +/// Minimum interval between warnings in each pool-event category. +const WARNING_INTERVAL_MS: u64 = 30_000; + +/// Pool-wide events avoid changing the existing pool type or checkout API. +#[derive(Debug)] +pub(super) struct PoolEvents { + /// Monotonic reference time for rate limiting. + started: Instant, + /// Last slow checkout warning timestamp. + checkout_warning: AtomicU64, + /// Last long connection lifetime warning timestamp. + hold_warning: AtomicU64, + /// Last pool exhaustion warning timestamp. + timeout_warning: AtomicU64, +} + +impl PoolEvents { + /// Initialize independent per-pool warning budgets. + pub(super) fn new() -> Self { + Self { + started: Instant::now(), + checkout_warning: AtomicU64::new(0), + hold_warning: AtomicU64::new(0), + timeout_warning: AtomicU64::new(0), + } + } + + /// Atomically grant at most one warning per interval and category. + fn should_warn(&self, last: &AtomicU64) -> bool { + let now = u64::try_from(self.started.elapsed().as_millis()) + .unwrap_or(u64::MAX) + .saturating_add(1); + let previous = last.load(Ordering::Relaxed); + (previous == 0 || now.saturating_sub(previous) >= WARNING_INTERVAL_MS) + && last + .compare_exchange(previous, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + } +} + +impl r2d2::HandleEvent for PoolEvents { + fn handle_checkout(&self, event: r2d2::event::CheckoutEvent) { + if event.duration() >= SLOW && self.should_warn(&self.checkout_warning) { + tracing::warn!(target: "db", event = "slow_checkout", + connection_id = event.connection_id(), duration_ms = %event.duration().as_millis(), + "database pool checkout waited longer than 250 ms"); + } + } + + fn handle_checkin(&self, event: r2d2::event::CheckinEvent) { + if event.duration() >= SLOW && self.should_warn(&self.hold_warning) { + tracing::warn!(target: "db", event = "long_connection_hold", + connection_id = event.connection_id(), duration_ms = %event.duration().as_millis(), + "database connection held longer than 250 ms"); + } + } + + fn handle_timeout(&self, event: r2d2::event::TimeoutEvent) { + if self.should_warn(&self.timeout_warning) { + tracing::warn!(target: "db", event = "pool_timeout", timeout_ms = %event.timeout().as_millis(), + "database pool checkout timed out"); + } + } +} + +/// Measure lock acquisition separately from the posting write-lock window. +#[derive(Debug)] +pub(super) struct WriteTiming { + /// Fixed operation name, never request data. + operation: &'static str, + /// Time the write lock was acquired. + acquired: Instant, + /// Time spent waiting to begin. + wait: Duration, +} + +impl WriteTiming { + /// Acquire the immediate lock and start transaction timing. + pub(super) fn begin(conn: &rusqlite::Connection, operation: &'static str) -> Result { + let started = Instant::now(); + let begin = conn.execute_batch("BEGIN IMMEDIATE"); + let wait = started.elapsed(); + if begin.is_err() || wait >= SLOW { + tracing::debug!(target: "db", event = "write_lock_wait", operation, + wait_ms = %wait.as_millis(), failed = begin.is_err(), + "SQLite immediate write-lock acquisition"); + } + begin.context(operation)?; + Ok(Self { + operation, + acquired: Instant::now(), + wait, + }) + } +} + +impl Drop for WriteTiming { + fn drop(&mut self) { + let held = self.acquired.elapsed(); + if held >= SLOW || self.wait >= SLOW { + // Explicit db=debug enables detailed write diagnostics; overload + // does not produce an unbounded stream at normal info/warn levels. + tracing::debug!(target: "db", event = "posting_transaction", operation = self.operation, + wait_ms = %self.wait.as_millis(), transaction_ms = %held.as_millis(), + "SQLite posting write-lock duration"); + } + } +} + +/// Fixed-label SQL timing for the measured read paths; parameters stay private. +pub(super) struct QueryTiming { + /// Static operation label. + operation: &'static str, + /// Monotonic query start. + started: Instant, +} + +impl QueryTiming { + /// Begin a read measurement without additional queries or allocations. + pub(super) fn start(operation: &'static str) -> Self { + Self { + operation, + started: Instant::now(), + } + } +} + +impl Drop for QueryTiming { + fn drop(&mut self) { + let elapsed = self.started.elapsed(); + if elapsed >= SLOW { + tracing::debug!(target: "db", event = "slow_query", operation = self.operation, + duration_ms = %elapsed.as_millis(), "SQLite read query exceeded 250 ms"); + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 4e1616db..3a43a0af 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -13,6 +13,8 @@ pub mod admin; pub mod banners; /// Board configuration, statistics, and deletion operations. pub mod boards; +/// Thresholded timing for pooled connections and atomic posting writes. +mod diagnostics; /// Durable filesystem-operation records. mod fs_ops; /// Database schema-version bookkeeping. @@ -33,6 +35,9 @@ pub mod threads; mod types; /// Anonymous per-thread display preferences. mod user_thread_prefs; +#[cfg(test)] +/// Explicitly invoked populated SQLite workload and query-plan evidence. +mod workload; pub use pool::{first_run_check, has_no_admin, init_pool}; pub use types::{CachedFile, DbPool, NewPost}; diff --git a/src/db/pool.rs b/src/db/pool.rs index b1583054..3b49a23b 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -8,9 +8,9 @@ use super::schema::install_or_migrate_schema; use super::types::DbPool; /// Pragmas applied to every pooled `SQLite` connection. -const CONNECTION_PRAGMAS: &str = " +pub(super) const CONNECTION_PRAGMAS: &str = " PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; + PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA cache_size = -32000; PRAGMA temp_store = MEMORY; @@ -39,6 +39,7 @@ pub fn init_pool() -> Result { let pool_size = CONFIG.db_pool_size; let pool = Pool::builder() .max_size(pool_size) + .event_handler(Box::new(super::diagnostics::PoolEvents::new())) .connection_timeout(POOL_CONNECTION_TIMEOUT) .build(manager) .context("Failed to build database pool")?; @@ -47,7 +48,9 @@ pub fn init_pool() -> Result { install_or_migrate_schema(&conn)?; super::upsert_builtin_themes(&conn)?; - tracing::info!(target: "db", path = db_path, "Database initialised"); + tracing::info!(target: "db", path = db_path, pool_size, + checkout_timeout_ms = %POOL_CONNECTION_TIMEOUT.as_millis(), + "Database initialised"); Ok(pool) } @@ -117,6 +120,24 @@ pub fn has_no_admin(pool: &DbPool) -> bool { mod tests { use anyhow::Result; + #[test] + fn every_pool_connection_enforces_wal_full_and_foreign_keys() -> Result<()> { + let pool = super::init_test_pool()?; + let connections = (0..4) + .map(|_| pool.get()) + .collect::, _>>()?; + for conn in connections { + let mode: String = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?; + let synchronous: i64 = conn.query_row("PRAGMA synchronous", [], |row| row.get(0))?; + let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + anyhow::ensure!( + mode == "wal" && synchronous == 2 && foreign_keys, + "pooled connection weakened durability or relationship enforcement" + ); + } + Ok(()) + } + #[test] #[expect( clippy::panic_in_result_fn, @@ -138,4 +159,112 @@ mod tests { ); Ok(()) } + + #[test] + fn exhausted_pool_returns_a_bounded_retryable_error() -> Result<()> { + let pool = super::init_test_pool()?; + let held = (0..4) + .map(|_| pool.get()) + .collect::, _>>()?; + let started = std::time::Instant::now(); + let error = pool + .get() + .err() + .ok_or_else(|| anyhow::anyhow!("exhausted pool supplied a connection"))?; + anyhow::ensure!( + started.elapsed() < std::time::Duration::from_secs(3), + "pool timeout exceeded its bound" + ); + anyhow::ensure!( + matches!( + crate::error::AppError::from(error), + crate::error::AppError::DbBusy + ), + "pool exhaustion must be retryable" + ); + drop(held); + anyhow::ensure!( + pool.get()?.is_autocommit(), + "returned connection inherited a transaction" + ); + Ok(()) + } + + #[test] + fn wal_readers_remain_usable_and_writer_contention_is_bounded() -> Result<()> { + let pool = super::init_test_pool()?; + let writer = pool.get()?; + writer.execute_batch("BEGIN IMMEDIATE")?; + writer.execute( + "INSERT INTO site_settings(key,value) VALUES('uncommitted','value')", + [], + )?; + let reader = pool.get()?; + let visible: i64 = reader.query_row( + "SELECT COUNT(*) FROM site_settings WHERE key='uncommitted'", + [], + |row| row.get(0), + )?; + anyhow::ensure!(visible == 0, "reader observed an uncommitted write"); + let started = std::time::Instant::now(); + let error = reader + .execute( + "INSERT INTO site_settings(key,value) VALUES('contended','value')", + [], + ) + .err() + .ok_or_else(|| anyhow::anyhow!("competing write unexpectedly succeeded"))?; + anyhow::ensure!( + started.elapsed() < std::time::Duration::from_secs(3), + "busy wait exceeded its bound" + ); + anyhow::ensure!( + matches!( + crate::error::AppError::from(error), + crate::error::AppError::DbBusy + ), + "busy writer must be retryable" + ); + writer.execute_batch("ROLLBACK")?; + anyhow::ensure!( + reader.is_autocommit(), + "busy failure left a transaction active" + ); + reader.execute( + "INSERT INTO site_settings(key,value) VALUES('recovered','value')", + [], + )?; + Ok(()) + } + + #[test] + fn active_read_snapshot_bounds_checkpoint_and_wal_backup_stays_complete() -> Result<()> { + let pool = super::init_test_pool()?; + let writer = pool.get()?; + crate::db::create_board(&writer, "wal", "WAL", "", false)?; + let reader = pool.get()?; + reader.execute_batch("BEGIN; SELECT COUNT(*) FROM boards;")?; + crate::db::create_board(&writer, "next", "Next", "", false)?; + let (log, checkpointed, busy) = crate::db::run_wal_checkpoint(&writer)?; + anyhow::ensure!( + busy == 1 && checkpointed < log, + "active reader must prevent a complete truncate checkpoint" + ); + let directory = tempfile::tempdir()?; + let path = directory.path().join("backup.sqlite3"); + writer.execute( + "VACUUM INTO ?1", + [path + .to_str() + .ok_or_else(|| anyhow::anyhow!("backup path"))?], + )?; + let snapshot = rusqlite::Connection::open(&path)?; + let boards: i64 = snapshot.query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0))?; + anyhow::ensure!(boards == 2, "WAL snapshot missed a committed board"); + crate::db::verify_database_schema(&snapshot)?; + reader.execute_batch("ROLLBACK")?; + let (_, _, busy) = crate::db::run_wal_checkpoint(&writer)?; + anyhow::ensure!(busy == 0, "checkpoint did not recover after reader release"); + Ok(()) + } } diff --git a/src/db/posts.rs b/src/db/posts.rs index 88f7d572..0ae740f2 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -903,23 +903,24 @@ pub fn search_posts( limit: i64, offset: i64, ) -> Result> { + let _timing = super::diagnostics::QueryTiming::start("search_posts"); let Some(fts_query) = to_fts_query(query) else { return Ok(Vec::new()); }; - let mut stmt = conn.prepare_cached( - "SELECT posts.id, posts.thread_id, posts.board_id, posts.name, posts.tripcode, + // Prefix MATCH queries drive from FTS so each matching row is looked up + // once, instead of running an FTS prefix probe for every board post. + let sql = "SELECT posts.id, posts.thread_id, posts.board_id, posts.name, posts.tripcode, posts.subject, posts.body, posts.body_html, posts.ip_hash, posts.file_path, posts.file_name, posts.file_size, posts.thumb_path, posts.mime_type, posts.created_at, posts.deletion_token, posts.is_op, posts.media_type, posts.audio_file_path, posts.audio_file_name, posts.audio_file_size, posts.audio_mime_type, posts.edited_at, posts.media_processing_state, posts.media_processing_error - FROM posts - JOIN posts_fts ON posts_fts.rowid = posts.id + FROM posts_fts CROSS JOIN posts ON posts.id = posts_fts.rowid WHERE posts.board_id = ?1 AND posts_fts MATCH ?2 ORDER BY posts.created_at DESC, posts.id DESC - LIMIT ?3 OFFSET ?4", - )?; + LIMIT ?3 OFFSET ?4"; + let mut stmt = conn.prepare_cached(sql)?; let posts = stmt .query_map(params![board_id, fts_query, limit, offset], map_post)? .collect::>>()?; @@ -933,17 +934,17 @@ pub fn count_search_results( board_id: i64, query: &str, ) -> Result { + let _timing = super::diagnostics::QueryTiming::start("count_search_results"); let Some(fts_query) = to_fts_query(query) else { return Ok(0); }; - Ok(conn.query_row( - "SELECT COUNT(*) - FROM posts - JOIN posts_fts ON posts_fts.rowid = posts.id - WHERE posts.board_id = ?1 AND posts_fts MATCH ?2", - params![board_id, fts_query], - |r| r.get(0), - )?) + Ok(conn + .prepare_cached( + "SELECT COUNT(*) + FROM posts_fts CROSS JOIN posts ON posts.id = posts_fts.rowid + WHERE posts.board_id = ?1 AND posts_fts MATCH ?2", + )? + .query_row(params![board_id, fts_query], |r| r.get(0))?) } // File deduplication @@ -2666,7 +2667,7 @@ mod tests { }; use crate::error::AppError; use anyhow::{Context as _, Result}; - use rusqlite::Connection; + use rusqlite::{params, Connection}; fn test_conn() -> Result { let conn = Connection::open_in_memory()?; @@ -2997,6 +2998,57 @@ mod tests { Ok(()) } + #[test] + fn search_prefixes_preserve_board_order_offset_and_counts() -> Result<()> { + let conn = test_conn()?; + let op = seed_search_post(&conn, "search", "common needle")?; + let board = get_board_by_short(&conn, "search")?.context("search board")?; + let thread = get_post(&conn, op)?.context("search OP")?.thread_id; + conn.execute( + "WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<1100) + INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token,created_at) + SELECT ?1,?2,CASE WHEN x%101=0 THEN 'common needle' ELSE 'common' END, + 'body','delete',1700000000+x%7 FROM n", + params![thread, board.id], + )?; + for term in ["common", "needle", "absent"] { + let total: i64 = conn.query_row( + "SELECT COUNT(*) FROM posts WHERE board_id=?1 AND body LIKE ?2", + params![board.id, format!("%{term}%")], + |row| row.get(0), + )?; + anyhow::ensure!( + count_search_results(&conn, board.id, term)? == total, + "search count changed" + ); + for offset in [0, 7, 1095] { + let mut statement = conn.prepare( + "SELECT id FROM posts WHERE board_id=?1 AND body LIKE ?2 + ORDER BY created_at DESC,id DESC LIMIT 20 OFFSET ?3", + )?; + let expected = statement + .query_map(params![board.id, format!("%{term}%"), offset], |row| { + row.get::<_, i64>(0) + })? + .collect::>>()?; + let posts = search_posts(&conn, board.id, term, 20, offset)?; + anyhow::ensure!( + expected == posts.iter().map(|post| post.id).collect::>(), + "search pagination changed" + ); + anyhow::ensure!( + search_posts(&conn, board.id + 1, term, 20, offset)?.is_empty(), + "search escaped board" + ); + } + } + conn.execute( + "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", + [], + )?; + Ok(()) + } + #[test] #[expect( clippy::panic_in_result_fn, diff --git a/src/db/schema.rs b/src/db/schema.rs index e27b0f13..13c7677a 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -278,6 +278,10 @@ const INDEX_SCHEMA_SQL: &str = " ON threads(board_id, sticky DESC, bumped_at DESC); CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at ASC); + CREATE INDEX IF NOT EXISTS idx_posts_thread_live + ON posts(thread_id, id); + CREATE INDEX IF NOT EXISTS idx_posts_board_ip_created + ON posts(board_id, ip_hash, created_at DESC); CREATE INDEX IF NOT EXISTS idx_posts_board ON posts(board_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bans_ip @@ -336,7 +340,9 @@ const INDEX_SCHEMA_SQL: &str = " /// Obsolete theme index accepted only during the known legacy repair path. const LEGACY_THEME_SORT_INDEX: &str = "idx_themes_enabled_sort"; /// Additive indexes introduced after the first package-version baseline. -const ADDITIVE_BASELINE_INDEXES: [&str; 9] = [ +const ADDITIVE_BASELINE_INDEXES: [&str; 11] = [ + "idx_posts_thread_live", + "idx_posts_board_ip_created", "idx_posts_file_path", "idx_posts_thumb_path", "idx_posts_audio_file_path", diff --git a/src/db/threads.rs b/src/db/threads.rs index 29915f21..493c8e45 100644 --- a/src/db/threads.rs +++ b/src/db/threads.rs @@ -127,7 +127,7 @@ fn collect_thread_file_paths( /// /// A left-join aggregation computes image counts in one pass. Callers must /// group by thread to preserve one output row per thread. -const THREAD_SELECT: &str = " +pub(super) const THREAD_SELECT: &str = " SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, t.locked, t.sticky, t.reply_count, op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, @@ -149,16 +149,52 @@ pub fn get_threads_for_board( limit: i64, offset: i64, ) -> Result> { - let sql = format!( - "{THREAD_SELECT} - WHERE t.board_id = ?1 AND t.archived = 0 - GROUP BY t.id, op.id - ORDER BY t.sticky DESC, t.bumped_at DESC - LIMIT ?2 OFFSET ?3" - ); + load_thread_page(conn, board_id, limit, offset, false) +} + +/// Paginate threads before computing reply aggregates; exclude missing OPs first. +pub(super) fn thread_page_sql(archived: bool) -> String { + // Preserve the legacy aggregate's tie order for each listing, including + // deterministic boundaries when many replies bump in the same second. + let order = if archived { + "t.bumped_at DESC, t.id ASC" + } else { + "t.sticky DESC, t.bumped_at DESC, t.id DESC" + }; + format!( + "WITH page AS MATERIALIZED ( + SELECT t.* FROM threads t + WHERE t.board_id = ?1 AND t.archived = ?4 + AND EXISTS (SELECT 1 FROM posts op WHERE op.thread_id=t.id AND op.is_op=1) + ORDER BY {order} LIMIT ?2 OFFSET ?3 + ) + SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, + t.locked, t.sticky, t.reply_count, + op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, + t.archived, + (SELECT COUNT(*) FROM posts fp WHERE fp.thread_id=t.id AND fp.file_path IS NOT NULL), + COALESCE((SELECT MAX(fp.created_at) FROM posts fp WHERE fp.thread_id=t.id), t.created_at) + FROM page t JOIN posts op ON op.thread_id=t.id AND op.is_op=1 + ORDER BY {order}" + ) +} + +/// Execute an indexed page and its bounded per-thread aggregate probes. +fn load_thread_page( + conn: &rusqlite::Connection, + board_id: i64, + limit: i64, + offset: i64, + archived: bool, +) -> Result> { + let _timing = super::diagnostics::QueryTiming::start("thread_page"); + let sql = thread_page_sql(archived); let mut stmt = conn.prepare_cached(&sql)?; let threads = stmt - .query_map(params![board_id, limit, offset], map_thread)? + .query_map( + params![board_id, limit, offset, i64::from(archived)], + map_thread, + )? .collect::>>()?; Ok(threads) } @@ -218,13 +254,15 @@ pub(crate) enum PostCreationOutcome { #[derive(Debug, Clone, Copy)] /// Filesystem lifecycle context validated in a public post transaction. -pub(crate) struct PostFilesystemCommit<'a> { +pub(crate) struct PostFilesystemCommit<'a, F = fn(&rusqlite::Connection) -> Result<()>> { /// Durable publication operation for newly staged files. pending_fs_op: Option<&'a crate::pending_fs::PendingFsOpInsert>, /// Prior dedup cache hits that must still be valid under the write lock. deduplicated_paths: &'a [&'a str], /// Whether a new thread must atomically persist board-prune work. schedule_thread_prune: bool, + /// Posting policy checked after token replay and while holding the write lock. + validate: F, } impl<'a> PostFilesystemCommit<'a> { @@ -234,11 +272,29 @@ impl<'a> PostFilesystemCommit<'a> { pending_fs_op: Option<&'a crate::pending_fs::PendingFsOpInsert>, deduplicated_paths: &'a [&'a str], schedule_thread_prune: bool, + ) -> Self { + Self::new_with_validation( + pending_fs_op, + deduplicated_paths, + schedule_thread_prune, + |_| Ok(()), + ) + } +} + +impl<'a, F> PostFilesystemCommit<'a, F> { + /// Build creation context with request validation under the write lock. + pub(crate) const fn new_with_validation( + pending_fs_op: Option<&'a crate::pending_fs::PendingFsOpInsert>, + deduplicated_paths: &'a [&'a str], + schedule_thread_prune: bool, + validate: F, ) -> Self { Self { pending_fs_op, deduplicated_paths, schedule_thread_prune, + validate, } } } @@ -286,14 +342,13 @@ pub(crate) fn create_thread_submission( post: &super::NewPost, submission_token: &str, poll: Option<&PollInsert<'_>>, - filesystem: PostFilesystemCommit<'_>, + filesystem: PostFilesystemCommit<'_, impl FnOnce(&rusqlite::Connection) -> Result<()>>, ) -> Result)>> { // BEGIN IMMEDIATE acquires the write lock upfront to avoid SQLITE_BUSY // during the lock-upgrade step that DEFERRED transactions perform on first // write. With &Connection (not &mut Connection) we cannot use rusqlite's // typed Transaction::new(Immediate), so we issue the pragma directly. - conn.execute_batch("BEGIN IMMEDIATE") - .context("Failed to begin IMMEDIATE transaction for create_thread_with_optional_poll")?; + let _timing = super::diagnostics::WriteTiming::begin(conn, "create_thread")?; let result: Result)>> = (|| { if let Some(ip_hash) = post.ip_hash.as_deref() { @@ -304,6 +359,7 @@ pub(crate) fn create_thread_submission( } } + (filesystem.validate)(conn)?; validate_deduplicated_paths(conn, filesystem.deduplicated_paths)?; let thread_id: i64 = conn.query_row( @@ -401,10 +457,10 @@ pub(crate) fn create_reply_submission( post: &super::NewPost, submission_token: &str, should_bump: bool, - filesystem: PostFilesystemCommit<'_>, + filesystem: PostFilesystemCommit<'_, impl FnOnce(&rusqlite::Connection) -> Result<()>>, ) -> Result> { - conn.execute_batch("BEGIN IMMEDIATE") - .context("Failed to begin create_reply_with_thread_update transaction")?; + // Serialize token replay, policy/thread validation, count and bump decisions. + let _timing = super::diagnostics::WriteTiming::begin(conn, "create_reply")?; let result: Result> = (|| { if let Some(ip_hash) = post.ip_hash.as_deref() { @@ -415,6 +471,7 @@ pub(crate) fn create_reply_submission( } } + (filesystem.validate)(conn)?; validate_deduplicated_paths(conn, filesystem.deduplicated_paths)?; let flags = conn @@ -443,7 +500,9 @@ pub(crate) fn create_reply_submission( let updated = if should_bump { conn.execute( "UPDATE threads - SET bumped_at = unixepoch(), + SET bumped_at = CASE + WHEN reply_count < (SELECT bump_limit FROM boards WHERE id = ?2) + THEN unixepoch() ELSE bumped_at END, reply_count = reply_count + 1 WHERE id = ?1 AND board_id = ?2 AND locked = 0 AND archived = 0", params![post.thread_id, post.board_id], @@ -892,18 +951,7 @@ pub fn get_archived_threads_for_board( limit: i64, offset: i64, ) -> Result> { - let sql = format!( - "{THREAD_SELECT} - WHERE t.board_id = ?1 AND t.archived = 1 - GROUP BY t.id, op.id - ORDER BY t.bumped_at DESC - LIMIT ?2 OFFSET ?3" - ); - let mut stmt = conn.prepare_cached(&sql)?; - let threads = stmt - .query_map(params![board_id, limit, offset], map_thread)? - .collect::>>()?; - Ok(threads) + load_thread_page(conn, board_id, limit, offset, true) } /// Count archived threads for a board (used for archive pagination). @@ -1502,4 +1550,137 @@ mod tests { ); Ok(()) } + + #[test] + fn paged_thread_aggregates_match_the_unbounded_baseline() -> Result<()> { + let conn = test_conn()?; + let board = create_board(&conn, "page", "Page", "", false)?; + for number in 0..35 { + let thread = create_plain_thread(&conn, board, &format!("thread {number}"))?; + conn.execute( + "UPDATE threads SET bumped_at=?1, sticky=?2, archived=?3 WHERE id=?4", + params![ + 1_700_000_000 + number / 7, + i64::from(number % 11 == 0), + i64::from(number % 5 == 0), + thread + ], + )?; + for reply in 0..number % 7 { + conn.execute("INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token,file_path,created_at) + VALUES(?1,?2,'reply','reply','delete',?3,?4)", + params![thread,board,(reply%2==0).then(|| format!("page/file-{number}-{reply}")),1_700_000_100+reply])?; + } + } + // A missing OP must be excluded before pagination, matching the old JOIN. + conn.execute( + "INSERT INTO threads(board_id,bumped_at,sticky) VALUES(?1,1900000000,1)", + [board], + )?; + for archived in [false, true] { + let order = if archived { + "t.bumped_at DESC" + } else { + "t.sticky DESC,t.bumped_at DESC" + }; + let old = format!( + "{} WHERE t.board_id=?1 AND t.archived=?4 GROUP BY t.id,op.id ORDER BY {order} LIMIT ?2 OFFSET ?3", + super::THREAD_SELECT + ); + for (limit, offset) in [ + (1, 0), + (10, 0), + (10, 10), + (100, 0), + (5, 99), + (0, 0), + (-1, 2), + ] { + let baseline = conn + .prepare(&old)? + .query_map( + params![board, limit, offset, i64::from(archived)], + super::map_thread, + )? + .collect::>>()?; + let paged = super::load_thread_page(&conn, board, limit, offset, archived)?; + let baseline_ids: Vec<_> = baseline.iter().map(|thread| thread.id).collect(); + let paged_ids: Vec<_> = paged.iter().map(|thread| thread.id).collect(); + anyhow::ensure!( + serde_json::to_value(baseline)? == serde_json::to_value(paged)?, + "page aggregates changed: archived={archived}, limit={limit}, offset={offset}, baseline={baseline_ids:?}, paged={paged_ids:?}" + ); + } + } + Ok(()) + } + + #[test] + fn concurrent_bump_requests_use_the_current_count_and_limit() -> Result<()> { + let pool = crate::db::init_test_pool()?; + let conn = pool.get()?; + let board = create_board(&conn, "bump", "Bump", "", false)?; + conn.execute("UPDATE boards SET bump_limit=1 WHERE id=?1", [board])?; + let thread = create_plain_thread(&conn, board, "target")?; + let op = crate::db::get_posts_for_thread(&conn, thread)? + .into_iter() + .next() + .context("OP")?; + let mut post = NewPost { + thread_id: thread, + board_id: board, + name: "anon".into(), + tripcode: None, + subject: None, + body: "reply".into(), + body_html: "reply".into(), + ip_hash: None, + file_path: None, + file_name: None, + file_size: None, + thumb_path: None, + mime_type: None, + media_type: None, + audio_file_path: None, + audio_file_name: None, + audio_file_size: None, + audio_mime_type: None, + deletion_token: op.deletion_token, + is_op: false, + }; + post.ip_hash = Some("bump-actor".into()); + drop(conn); + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| -> Result<()> { + let handles:Vec<_>=(0..2).map(|number| { + let pool=&pool; let post=&post; let barrier=&barrier; + scope.spawn(move || -> Result<()> { + barrier.wait(); + let conn=pool.get()?; + super::create_reply_submission(&conn,post,&format!("bump-{number}"),true, + PostFilesystemCommit::new_with_validation(None,&[],false,|conn:&Connection| { + // Give the second serialized reply a distinguishable previous + // timestamp; no sleep or wall-clock assumption is required. + conn.execute("UPDATE threads SET bumped_at=42 WHERE id=?1 AND reply_count=1",[thread])?; + Ok(()) + }))?; + Ok(()) + }) + }).collect(); + for handle in handles { + handle + .join() + .map_err(|_| anyhow::anyhow!("reply worker panicked"))??; + } + Ok(()) + })?; + let conn = pool.get()?; + let state = crate::db::get_thread(&conn, thread)?.context("thread")?; + anyhow::ensure!( + state.reply_count == 2 && state.bumped_at == 42, + "concurrent replies exceeded the bump limit" + ); + crate::db::verify_database_schema(&conn)?; + Ok(()) + } } diff --git a/src/db/workload.rs b/src/db/workload.rs new file mode 100644 index 00000000..4a9a5933 --- /dev/null +++ b/src/db/workload.rs @@ -0,0 +1,857 @@ +//! Offline, opt-in measurements against the bundled SQLite and real DB helpers. + +use anyhow::{ensure, Context as _, Result}; +use rusqlite::{params, Connection}; +use serde::Serialize; +use std::{path::Path, sync::Arc, time::Instant}; + +/// Number of concurrent callers used for each measured case. +const WORKERS: u32 = 8; +/// Number of measured operations per caller. +const OPERATIONS: u32 = 400; + +/// One operation's elapsed times in microseconds. +#[derive(Debug, Serialize)] +struct Sample { + /// Full checkout plus operation latency. + elapsed_us: u128, + /// Time waiting for a pooled connection. + checkout_us: u128, + /// Time acquiring an immediate SQLite write lock (zero for reads). + write_wait_us: u128, + /// Time holding a write transaction after lock acquisition. + transaction_us: u128, + /// Whether SQLite or the pool rejected the operation. + failed: bool, + /// Whether the rejection was SQLite busy/locked. + busy: bool, + /// Whether the pool checkout timed out. + pool_timeout: bool, +} + +/// Nearest-rank latency summary, with integer units avoiding precision loss. +#[derive(Debug, Serialize)] +struct Latencies { + /// Median microseconds. + #[serde(rename = "p50_us")] + p50: u128, + /// 95th percentile microseconds. + #[serde(rename = "p95_us")] + p95: u128, + /// 99th percentile microseconds. + #[serde(rename = "p99_us")] + p99: u128, + /// Maximum microseconds. + #[serde(rename = "max_us")] + max: u128, +} + +/// Result for one pool/durability/workload combination. +#[derive(Debug, Serialize)] +struct Measurement { + /// Configured maximum connections. + pool_size: u32, + /// Explicit SQLite synchronous mode. + synchronous: String, + /// Workload traffic mix. + workload: String, + /// Number of attempted operations. + operations: u32, + /// Successful operations per wall-clock second. + operations_per_second: f64, + /// Total request latency. + latency: Latencies, + /// Read-only operation latency including checkout. + read_latency: Latencies, + /// Write operation latency including checkout and lock acquisition. + write_latency: Latencies, + /// Pool checkout latency. + checkout: Latencies, + /// Immediate lock acquisition latency for writes only. + write_wait: Latencies, + /// Write lock duration after acquisition, including commit. + transaction: Latencies, + /// Requests whose checkout exceeded 100 microseconds. + checkout_over_100us: usize, + /// Failed operations of all kinds. + failures: u32, + /// SQLite busy or locked errors. + busy_errors: usize, + /// Pool timeouts. + pool_timeouts: usize, + /// Database bytes, including pages resident in WAL. + database_bytes: i64, + /// WAL file bytes before passive checkpoint. + wal_bytes: u64, + /// WAL pages and progress reported by a passive checkpoint. + checkpoint: (i64, i64, i64), + /// Passive checkpoint elapsed microseconds after workers release snapshots. + checkpoint_us: u128, + /// Resident memory in KiB, if the host provides ps. + rss_kib: Option, + /// Process CPU seconds during this case, if the host provides ps. + cpu_seconds: Option, +} + +/// Explain output for a named production query shape. +#[derive(Debug, Serialize)] +struct Plan { + /// Stable description of the query. + name: String, + /// Planner details from the populated database. + details: Vec, +} + +/// Generate deterministic representative content, including FTS and media rows. +fn seed(conn: &Connection) -> Result<()> { + super::schema::install_or_migrate_schema(conn)?; + conn.execute_batch( + "BEGIN IMMEDIATE; + WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<8) + INSERT INTO boards(id,short_name,name) SELECT x,'b'||x,'Board '||x FROM n; + WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<800) + INSERT INTO threads(id,board_id,subject,archived,reply_count,bumped_at) + SELECT x,1+(x-1)%8,'Thread '||x,x%5=0,49,1700000000+x FROM n; + WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<40000) + INSERT INTO posts(id,thread_id,board_id,body,body_html,ip_hash,deletion_token, + is_op,created_at,file_path,thumb_path,mime_type,media_type,file_size) + SELECT x,1+(x-1)/50,1+((x-1)/50)%8, + CASE WHEN x%101=0 THEN 'rare benchmark needle' ELSE 'representative post text' END, + 'representative post text','actor-'||(x%1000),'fixture-token',(x-1)%50=0, + 1700000000+x,CASE WHEN x%4=0 THEN 'b'||(1+((x-1)/50)%8)||'/src/'||x||'.png' END, + CASE WHEN x%4=0 THEN 'b'||(1+((x-1)/50)%8)||'/thumb/'||x||'.png' END, + CASE WHEN x%4=0 THEN 'image/png' END,CASE WHEN x%4=0 THEN 'image' END, + CASE WHEN x%4=0 THEN 1024 END FROM n; + INSERT INTO file_hashes(sha256,file_path,thumb_path,mime_type) + SELECT printf('%064x',id),file_path,thumb_path,mime_type FROM posts WHERE file_path IS NOT NULL; + INSERT INTO bans(ip_hash,reason) SELECT DISTINCT ip_hash,'fixture' FROM posts WHERE id%101=0; + INSERT INTO admin_users(id,username,password_hash) VALUES(1,'fixture-admin','fixture-hash'); + INSERT INTO reports(post_id,thread_id,board_id,reason,reporter_hash) + SELECT id,thread_id,board_id,'fixture','reporter-'||id FROM posts WHERE id%83=0; + INSERT INTO mod_log(admin_id,admin_name,action,target_type,target_id) + SELECT 1,'fixture-admin','fixture','post',id FROM posts WHERE id%37=0; + INSERT INTO polls(thread_id,question,expires_at) + SELECT id,'fixture poll',unixepoch()+86400 FROM threads WHERE id%7=0; + INSERT INTO poll_options(poll_id,text,position) SELECT id,'yes',0 FROM polls; + INSERT INTO poll_options(poll_id,text,position) SELECT id,'no',1 FROM polls; + INSERT INTO poll_votes(poll_id,option_id,ip_hash) + SELECT poll_id,id,'fixture-voter' FROM poll_options WHERE position=0; + INSERT INTO background_jobs(job_type,payload,status) + SELECT 'spam_check','{}',CASE WHEN id%2=0 THEN 'done' ELSE 'pending' END FROM posts WHERE id%31=0; + COMMIT;", + )?; + Ok(()) +} + +/// Create warmed file-backed production-configured connections for a case. +fn pool(path: &Path, size: u32, synchronous: &'static str) -> Result { + let manager = r2d2_sqlite::SqliteConnectionManager::file(path).with_init(move |conn| { + conn.execute_batch(super::pool::CONNECTION_PRAGMAS)?; + conn.pragma_update(None, "synchronous", synchronous) + }); + Ok(r2d2::Pool::builder() + .max_size(size) + .min_idle(Some(size)) + .connection_timeout(std::time::Duration::from_secs(1)) + .build(manager)?) +} + +/// Run production read helpers, covering board/catalog/thread/polling/FTS/admin. +fn read(conn: &Connection, operation: u32) -> Result<()> { + let thread = i64::from(operation % 800 + 1); + let board = (thread - 1) % 8 + 1; + match operation % 7 { + 0 => drop(super::get_threads_for_board(conn, board, 10, 0)?), + 1 => drop(super::get_threads_for_board(conn, board, 100, 0)?), + 2 => { + drop(super::get_thread(conn, thread)?); + drop(super::get_posts_for_thread(conn, thread)?); + } + 3 => drop(super::get_new_posts_since( + conn, + board, + thread, + thread * 50 - 5, + 100, + )?), + 4 => drop(super::search_posts(conn, board, "needle", 20, 0)?), + 5 => drop(super::get_open_reports(conn)?), + _ => { + drop(super::get_poll_for_thread(conn, thread, "fixture-voter")?); + drop(super::find_file_by_hash( + conn, + &format!("{:064x}", operation * 4 + 4), + )?); + } + } + Ok(()) +} + +/// Run a short post transaction with FTS, counters and submission-token work. +fn write(conn: &Connection, worker: u32, operation: u32) -> Result<()> { + let thread = i64::from((operation * WORKERS + worker) % 799 + 1); + let board = (thread - 1) % 8 + 1; + conn.execute( + "INSERT INTO posts(thread_id,board_id,body,body_html,ip_hash,deletion_token) + VALUES(?1,?2,'workload reply','workload reply',?3,'workload-delete')", + params![thread, board, format!("workload-{worker}")], + )?; + let post_id = conn.last_insert_rowid(); + conn.execute( + "UPDATE threads SET reply_count=reply_count+1,bumped_at=unixepoch() WHERE id=?1", + [thread], + )?; + super::record_post_submission( + conn, + &format!("workload-{worker}-{operation}"), + &format!("workload-{worker}"), + board, + thread, + post_id, + false, + )?; + if operation.is_multiple_of(17) { + super::enqueue_job(conn, "spam_check", "{}")?; + if let Some((job, _)) = super::claim_next_job(conn)? { + super::complete_job(conn, job)?; + } + } + if operation.is_multiple_of(23) { + conn.execute( + "INSERT OR IGNORE INTO poll_votes(poll_id,option_id,ip_hash) + SELECT poll_id,id,?1 FROM poll_options WHERE position=0 LIMIT 1", + [format!("workload-{worker}-{operation}")], + )?; + } + if operation.is_multiple_of(41) { + conn.execute( + "DELETE FROM background_jobs WHERE id IN + (SELECT id FROM background_jobs WHERE status='done' LIMIT 4)", + [], + )?; + } + Ok(()) +} + +/// Record a single operation without hiding failures behind retries. +fn sample(pool: &super::DbPool, worker: u32, operation: u32, write_percent: u32) -> Sample { + let started = Instant::now(); + let acquired = pool.get(); + let checkout_us = started.elapsed().as_micros(); + let pool_timeout = acquired.is_err(); + let mut write_wait_us = 0; + let mut transaction_us = 0; + let result = acquired.map_err(anyhow::Error::from).and_then(|conn| { + if (operation * 37 + worker * 13) % 100 >= write_percent { + return read(&conn, operation); + } + let wait = Instant::now(); + let begin = conn.execute_batch("BEGIN IMMEDIATE"); + write_wait_us = wait.elapsed().as_micros().max(1); + begin?; + let locked = Instant::now(); + let result = write(&conn, worker, operation) + .and_then(|()| super::commit_transaction(&conn, "commit workload reply")); + if result.is_err() { + drop(conn.execute_batch("ROLLBACK")); + } + transaction_us = locked.elapsed().as_micros().max(1); + result + }); + let busy = result.as_ref().err().is_some_and(|error| { + error.downcast_ref::().is_some_and(|error| { + matches!(error, rusqlite::Error::SqliteFailure(code, _) + if matches!(code.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)) + }) + }); + Sample { + elapsed_us: started.elapsed().as_micros(), + checkout_us, + write_wait_us, + transaction_us, + failed: result.is_err(), + busy, + pool_timeout, + } +} + +/// Compute nearest-rank percentiles over one elapsed-time series. +fn latencies(values: impl Iterator) -> Latencies { + let mut values: Vec<_> = values.collect(); + values.sort_unstable(); + let percentile = |percent: usize| { + values + .get((values.len() * percent).div_ceil(100).saturating_sub(1)) + .copied() + .unwrap_or(0) + }; + Latencies { + p50: percentile(50), + p95: percentile(95), + p99: percentile(99), + max: values.last().copied().unwrap_or(0), + } +} + +/// Read this process's resident memory using an optional host utility. +fn rss_kib() -> Option { + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + String::from_utf8(output.stdout).ok()?.trim().parse().ok() +} + +/// Read accumulated process CPU time without adding a platform dependency. +fn cpu_seconds() -> Option { + let output = std::process::Command::new("ps") + .args(["-o", "time=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + let time = String::from_utf8(output.stdout).ok()?; + time.trim().split(':').try_fold(0.0_f64, |total, part| { + part.parse::() + .ok() + .map(|part| total.mul_add(60.0, part)) + }) +} + +/// Verify persisted relationships, FTS and counters after concurrent operations. +fn verify_stress_state(conn: &Connection) -> Result<()> { + let integrity: String = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0))?; + ensure!(integrity == "ok", "stress integrity check: {integrity}"); + ensure!( + !conn.prepare("PRAGMA foreign_key_check")?.exists([])?, + "stress foreign-key violation" + ); + conn.execute( + "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", + [], + )?; + super::verify_database_schema(conn)?; + let incorrect_counts: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM threads t WHERE t.reply_count != + (SELECT COUNT(*) FROM posts p WHERE p.thread_id=t.id AND p.is_op=0))", + [], + |row| row.get(0), + )?; + ensure!( + !incorrect_counts, + "stress transaction left an incorrect reply count" + ); + Ok(()) +} + +/// Stress one mix; verify database and FTS before returning the measurement. +fn measure(path: &Path, size: u32, sync: &'static str, mix: (&str, u32)) -> Result { + let pool = pool(path, size, sync)?; + let barrier = Arc::new(std::sync::Barrier::new(usize::try_from(WORKERS)?)); + let cpu_started = cpu_seconds(); + let started = Instant::now(); + let samples = std::thread::scope(|scope| -> Result> { + let handles: Vec<_> = (0..WORKERS) + .map(|worker| { + let pool = &pool; + let barrier = Arc::clone(&barrier); + scope.spawn(move || { + barrier.wait(); + (0..OPERATIONS) + .map(|operation| sample(pool, worker, operation, mix.1)) + .collect::>() + }) + }) + .collect(); + let mut samples = Vec::new(); + for handle in handles { + samples.extend( + handle + .join() + .map_err(|_| anyhow::anyhow!("workload worker panicked"))?, + ); + } + Ok(samples) + })?; + let elapsed = started.elapsed(); + let cpu_elapsed = cpu_started + .zip(cpu_seconds()) + .map(|(before, after)| (after - before).max(0.0)); + let conn = pool.get()?; + let wal_bytes = std::fs::metadata(path.with_extension("sqlite3-wal")).map_or(0, |m| m.len()); + let checkpoint_started = Instant::now(); + let checkpoint = conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })?; + let checkpoint_us = checkpoint_started.elapsed().as_micros(); + verify_stress_state(&conn)?; + let failures = u32::try_from(samples.iter().filter(|s| s.failed).count())?; + Ok(Measurement { + pool_size: size, + synchronous: sync.to_owned(), + workload: mix.0.to_owned(), + operations: WORKERS * OPERATIONS, + operations_per_second: f64::from(WORKERS * OPERATIONS - failures) / elapsed.as_secs_f64(), + latency: latencies(samples.iter().map(|s| s.elapsed_us)), + read_latency: latencies( + samples + .iter() + .filter(|s| !s.pool_timeout && s.write_wait_us == 0) + .map(|s| s.elapsed_us), + ), + write_latency: latencies( + samples + .iter() + .filter(|s| s.write_wait_us > 0) + .map(|s| s.elapsed_us), + ), + checkout: latencies(samples.iter().map(|s| s.checkout_us)), + write_wait: latencies( + samples + .iter() + .filter(|s| s.write_wait_us > 0) + .map(|s| s.write_wait_us), + ), + transaction: latencies( + samples + .iter() + .filter(|s| s.transaction_us > 0) + .map(|s| s.transaction_us), + ), + checkout_over_100us: samples.iter().filter(|s| s.checkout_us > 100).count(), + failures, + busy_errors: samples.iter().filter(|s| s.busy).count(), + pool_timeouts: samples.iter().filter(|s| s.pool_timeout).count(), + database_bytes: super::get_db_size_bytes(&conn)?, + wal_bytes, + checkpoint, + checkpoint_us, + rss_kib: rss_kib(), + cpu_seconds: cpu_elapsed, + }) +} + +/// Profile production query shapes on the exact generated schema and data. +fn plans(conn: &Connection) -> Result> { + let board = super::threads::thread_page_sql(false) + .replace("?1", "1") + .replace("?2", "10") + .replace("?3", "0") + .replace("?4", "0"); + let catalog = super::threads::thread_page_sql(false) + .replace("?1", "1") + .replace("?2", "100") + .replace("?3", "0") + .replace("?4", "0"); + let queries = [ + ("board_index", board.as_str()), + ("catalog", catalog.as_str()), + ("thread_loading", "SELECT * FROM posts WHERE thread_id=1 ORDER BY created_at ASC,id ASC"), + ("latest_post", "SELECT created_at,id FROM posts WHERE board_id=1 ORDER BY created_at DESC,id DESC LIMIT 1"), + ("thread_bump", "UPDATE threads SET reply_count=reply_count+1,bumped_at=CASE WHEN reply_count<(SELECT bump_limit FROM boards WHERE id=1) THEN unixepoch() ELSE bumped_at END WHERE id=1 AND board_id=1 AND locked=0 AND archived=0"), + ("admin_overview", "SELECT (SELECT COUNT(*) FROM posts),(SELECT COUNT(*) FROM threads),(SELECT COUNT(*) FROM reports WHERE status='open')"), + ("report_lookup", "SELECT id FROM reports WHERE post_id=83 AND reporter_hash='reporter-83' AND status='open'"), + ("backup_board_inventory", "SELECT id,file_path,thumb_path,audio_file_path FROM posts WHERE board_id=1 ORDER BY id"), + ( + "live_poll", + "SELECT * FROM posts WHERE board_id=1 AND thread_id=1 AND id>45 ORDER BY id LIMIT 100", + ), + ( + "cooldown", + "SELECT MAX(created_at) FROM posts WHERE board_id=1 AND ip_hash='actor-1'", + ), + ( + "latest_thread", + "SELECT created_at,id FROM threads WHERE board_id=1 AND archived=0 ORDER BY created_at DESC,id DESC LIMIT 1", + ), + ( + "prune", + "SELECT id FROM threads WHERE board_id=1 AND archived=0 AND sticky=0 ORDER BY bumped_at DESC LIMIT -1 OFFSET 100", + ), + ( + "search_selective", + "SELECT p.id FROM posts_fts CROSS JOIN posts p ON p.id=posts_fts.rowid WHERE p.board_id=1 AND posts_fts MATCH '\"needle\"*' ORDER BY p.created_at DESC,p.id DESC LIMIT 20", + ), + ( + "reports", + "SELECT * FROM reports WHERE status='open' ORDER BY created_at DESC LIMIT 20", + ), + ( + "bans", + "SELECT reason FROM bans WHERE ip_hash='actor-1' AND (expires_at IS NULL OR expires_at>unixepoch())", + ), + ( + "job_claim", + "SELECT id FROM background_jobs WHERE status='pending' AND attempts<3 ORDER BY priority DESC,created_at LIMIT 1", + ), + ( + "file_refs", + "SELECT 1 FROM posts WHERE file_path='b1/src/4.png' OR thumb_path='b1/src/4.png' OR audio_file_path='b1/src/4.png' LIMIT 1", + ), + ( + "dedup", + "SELECT * FROM file_hashes WHERE sha256=printf('%064x',4)", + ), + ( + "mod_log", + "SELECT * FROM mod_log ORDER BY created_at DESC LIMIT 50", + ), + ]; + queries + .into_iter() + .map(|(name, sql)| { + let details = conn + .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))? + .query_map([], |r| r.get::<_, String>(3))? + .collect::>()?; + Ok(Plan { + name: name.to_owned(), + details, + }) + }) + .collect() +} + +/// Explicitly invoked benchmark; temporary databases never enter the repository. +#[test] +#[ignore = "populated offline performance matrix; run explicitly with RUSTCHAN_DB_EVIDENCE set"] +fn benchmark() -> Result<()> { + let output = std::env::var("RUSTCHAN_DB_EVIDENCE") + .context("set RUSTCHAN_DB_EVIDENCE to a JSON result path")?; + let directory = tempfile::tempdir()?; + let seed_path = directory.path().join("seed.sqlite3"); + let seed_conn = Connection::open(&seed_path)?; + seed_conn.execute_batch(super::pool::CONNECTION_PRAGMAS)?; + seed(&seed_conn)?; + let query_plans = plans(&seed_conn)?; + seed_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; + drop(seed_conn); + let mut measurements = Vec::new(); + for sync in ["NORMAL", "FULL"] { + for size in [2, 4, 8, 12, 16] { + for mix in [("read_heavy", 5), ("write_burst", 70), ("mixed", 25)] { + let path = directory + .path() + .join(format!("{sync}-{size}-{}.sqlite3", mix.0)); + std::fs::copy(&seed_path, &path)?; + measurements.push(measure(&path, size, sync, mix)?); + std::fs::write( + &output, + serde_json::to_vec_pretty(&serde_json::json!({ + "sqlite_version":rusqlite::version(),"workers":WORKERS, + "fixture":{"boards":8,"threads":800,"posts":40000}, + "query_plans":query_plans,"measurements":measurements, + "limitations":"SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss" + }))?, + )?; + } + } + } + std::fs::write( + output, + serde_json::to_vec_pretty(&serde_json::json!({ + "sqlite_version": rusqlite::version(), "workers":WORKERS, + "fixture":{"boards":8,"threads":800,"posts":40000}, + "query_plans":query_plans, "measurements":measurements, + "limitations":"SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss" + }))?, + )?; + Ok(()) +} + +/// Child process guard prevents a failing parent test from leaving a writer alive. +#[derive(Debug)] +struct CrashChild(std::process::Child); + +impl Drop for CrashChild { + fn drop(&mut self) { + drop(self.0.kill()); + drop(self.0.wait()); + } +} + +/// Child-only workload; parent kills it with an open WAL write transaction. +#[test] +#[ignore = "invoked only by the crash/restart parent regression"] +fn crash_writer_child() -> Result<()> { + let path = std::path::PathBuf::from(std::env::var("RUSTCHAN_DB_CRASH_PATH")?); + ensure!(!path.exists(), "crash fixture must be a fresh database"); + let conn = Connection::open(&path)?; + conn.execute_batch(super::pool::CONNECTION_PRAGMAS)?; + conn.pragma_update( + None, + "synchronous", + std::env::var("RUSTCHAN_DB_CRASH_SYNC")?, + )?; + super::schema::install_or_migrate_schema(&conn)?; + conn.execute_batch( + r#"BEGIN IMMEDIATE; + INSERT INTO boards(id,short_name,name) VALUES(1,'crash','Crash'); + INSERT INTO threads(id,board_id) VALUES(1,1); + INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token,is_op) + VALUES(1,1,'op','op','delete',1); + INSERT INTO background_jobs(job_type,payload,status,attempts) + VALUES('spam_check','{}','running',1); + INSERT INTO pending_fs_ops(id,kind,payload_json) + VALUES('crash-delete','delete_files','{"paths":["crash/src/remove.txt"],"dirs":[]}'); + COMMIT;"#, + )?; + let root = path + .parent() + .context("crash fixture parent")? + .join("uploads"); + std::fs::create_dir_all(root.join("crash/src"))?; + std::fs::write(root.join("crash/src/remove.txt"), b"recoverable cleanup")?; + // The readiness marker is created only inside a later write transaction, + // after committed data, FTS content, a job and a filesystem intent exist. + for number in 0_u64.. { + conn.execute_batch("BEGIN IMMEDIATE")?; + conn.execute( + "INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token) + VALUES(1,1,'crash reply','crash reply','delete')", + [], + )?; + conn.execute( + "UPDATE threads SET reply_count=reply_count+1 WHERE id=1", + [], + )?; + if number == 100 { + std::fs::write(path.with_extension("ready"), b"open transaction")?; + // Give the parent a deterministic open transaction to interrupt. + std::thread::sleep(std::time::Duration::from_secs(30)); + anyhow::bail!("crash parent failed to terminate the child within its deadline"); + } + super::commit_transaction(&conn, "commit crash workload")?; + } + Ok(()) +} + +/// Verify process-crash recovery for NORMAL and FULL without simulating power loss. +#[test] +fn abrupt_writer_termination_recovers_wal_fts_jobs_and_fs_intents() -> Result<()> { + for synchronous in ["NORMAL", "FULL"] { + let directory = tempfile::tempdir()?; + let path = directory.path().join("crash.sqlite3"); + let mut child = CrashChild( + std::process::Command::new(std::env::current_exe()?) + .args(["--ignored", "--exact", "db::workload::crash_writer_child"]) + .env("RUSTCHAN_DB_CRASH_PATH", &path) + .env("RUSTCHAN_DB_CRASH_SYNC", synchronous) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?, + ); + let deadline = Instant::now() + std::time::Duration::from_secs(20); + while !path.with_extension("ready").exists() { + ensure!( + child.0.try_wait()?.is_none(), + "crash writer exited before readiness" + ); + ensure!( + Instant::now() < deadline, + "crash writer readiness timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + child.0.kill()?; + child.0.wait()?; + let pool = pool(&path, 2, synchronous)?; + let conn = pool.get()?; + super::schema::install_or_migrate_schema(&conn)?; + super::verify_database_schema(&conn)?; + conn.execute( + "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", + [], + )?; + let (replies, count): (i64, i64) = conn.query_row( + "SELECT (SELECT COUNT(*) FROM posts WHERE is_op=0),reply_count FROM threads WHERE id=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + ensure!( + replies == 100 && count == replies, + "crash transaction was partially committed" + ); + ensure!( + conn.is_autocommit(), + "restart inherited an active transaction" + ); + super::recover_interrupted_background_jobs(&conn)?; + let status: String = + conn.query_row("SELECT status FROM background_jobs WHERE id=1", [], |r| { + r.get(0) + })?; + ensure!(status == "pending", "interrupted job was not recovered"); + drop(conn); + let uploads = directory.path().join("uploads"); + crate::pending_fs::reconcile_pending_fs_ops( + &pool, + uploads.to_str().context("uploads path")?, + )?; + ensure!( + !uploads.join("crash/src/remove.txt").exists(), + "durable filesystem intent did not replay" + ); + let conn = pool.get()?; + ensure!( + super::list_pending_fs_ops(&conn)?.is_empty(), + "filesystem intent was not completed" + ); + conn.execute_batch("BEGIN IMMEDIATE; ROLLBACK")?; + super::verify_database_schema(&conn)?; + } + Ok(()) +} + +/// Time one cached query and retain IDs to prove result equivalence. +fn timed_query(conn: &Connection, sql: &str) -> Result<(Latencies, Vec)> { + let mut timings = Vec::new(); + let mut ids = Vec::new(); + let mut statement = conn.prepare_cached(sql)?; + for _ in 0..25 { + let started = Instant::now(); + ids = statement + .query_map([], |r| r.get::<_, i64>(0))? + .collect::>()?; + timings.push(started.elapsed().as_micros()); + } + Ok((latencies(timings.into_iter()), ids)) +} + +/// Collect the actual access plan for a parameter-free profiling query. +fn explain(conn: &Connection, sql: &str) -> Result> { + let mut statement = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?; + let details = statement + .query_map([], |r| r.get(3))? + .collect::>()?; + Ok(details) +} + +/// Compare measured candidate queries/indexes against their baseline counterparts. +#[test] +#[ignore = "explicit populated query and index evidence; set RUSTCHAN_DB_QUERY_EVIDENCE"] +fn query_profile() -> Result<()> { + let output = std::env::var("RUSTCHAN_DB_QUERY_EVIDENCE")?; + let directory = tempfile::tempdir()?; + let conn = Connection::open(directory.path().join("profile.sqlite3"))?; + conn.execute_batch(super::pool::CONNECTION_PRAGMAS)?; + seed(&conn)?; + let mut evidence = Vec::new(); + for term in ["needle", "representative", "missingword"] { + for selection in ["p.id", "COUNT(*)"] { + let tail = if selection == "p.id" { + "ORDER BY p.created_at DESC,p.id DESC LIMIT 20" + } else { + "" + }; + let before = format!( + "SELECT {selection} FROM posts p JOIN posts_fts f ON f.rowid=p.id + WHERE p.board_id=1 AND posts_fts MATCH '\"{term}\"*' {tail}" + ); + let after = format!( + "SELECT {selection} FROM posts_fts CROSS JOIN posts p ON p.id=posts_fts.rowid + WHERE p.board_id=1 AND posts_fts MATCH '\"{term}\"*' {tail}" + ); + let (before_time, before_ids) = timed_query(&conn, &before)?; + let (after_time, after_ids) = timed_query(&conn, &after)?; + ensure!(before_ids == after_ids, "FTS query changed results"); + evidence.push(serde_json::json!({"query":format!("fts_{selection}_{term}"), + "before":before_time,"after":after_time,"before_plan":explain(&conn,&before)?,"after_plan":explain(&conn,&after)?})); + } + } + evidence.extend(profile_search(&conn)?); + let before_board = format!( + "{} WHERE t.board_id=1 AND t.archived=0 GROUP BY t.id,op.id + ORDER BY t.sticky DESC,t.bumped_at DESC LIMIT 10", + super::threads::THREAD_SELECT + ); + let after_board = super::threads::thread_page_sql(false) + .replace("?1", "1") + .replace("?2", "10") + .replace("?3", "0") + .replace("?4", "0"); + let (before_time, before_ids) = timed_query(&conn, &before_board)?; + let (after_time, after_ids) = timed_query(&conn, &after_board)?; + ensure!(before_ids == after_ids, "board page changed results"); + evidence.push( + serde_json::json!({"query":"board_page","before":before_time,"after":after_time, + "before_plan":explain(&conn,&before_board)?,"after_plan":explain(&conn,&after_board)?}), + ); + // Reconstruct the pre-index fixture, including reclaiming dropped pages, + // before measuring index storage and write cost. + conn.execute_batch( + "DROP INDEX IF EXISTS idx_posts_thread_live; + DROP INDEX IF EXISTS idx_posts_board_ip_created; + VACUUM;", + )?; + let queries = [ + ( + "live_poll", + "SELECT id FROM posts WHERE board_id=1 AND thread_id=1 AND id>45 ORDER BY id LIMIT 100", + ), + ( + "cooldown", + "SELECT MAX(created_at) FROM posts WHERE board_id=1 AND ip_hash='actor-1'", + ), + ]; + let before = queries + .iter() + .map(|(_, sql)| Ok((timed_query(&conn, sql)?, explain(&conn, sql)?))) + .collect::>>()?; + let write_before = profile_writes(&conn, 0)?; + let size_before = super::get_db_size_bytes(&conn)?; + conn.execute_batch("CREATE INDEX idx_posts_thread_live ON posts(thread_id,id); + CREATE INDEX idx_posts_board_ip_created ON posts(board_id,ip_hash,created_at DESC);")?; + let size_after = super::get_db_size_bytes(&conn)?; + for ((name, sql), ((before_time, _), before_plan)) in queries.into_iter().zip(before) { + let (after_time, _) = timed_query(&conn, sql)?; + evidence.push( + serde_json::json!({"query":name,"before":before_time,"after":after_time, + "before_plan":before_plan,"after_plan":explain(&conn,sql)?}), + ); + } + let write_after = profile_writes(&conn, 100)?; + std::fs::write( + output, + serde_json::to_vec_pretty(&serde_json::json!({"sqlite":rusqlite::version(), + "queries":evidence,"write_before":write_before,"write_after":write_after, + "database_bytes_before":size_before,"database_bytes_after":size_after}))?, + )?; + Ok(()) +} + +/// Compare full post projections using the actual sanitized prefix syntax. +fn profile_search(conn: &Connection) -> Result> { + let mut evidence = Vec::new(); + for term in ["needle", "representative", "missingword"] { + let before = format!("SELECT p.* FROM posts p JOIN posts_fts f ON f.rowid=p.id + WHERE p.board_id=1 AND posts_fts MATCH '\"{term}\"*' ORDER BY p.created_at DESC,p.id DESC LIMIT 20"); + let (before_time, ids) = timed_query(conn, &before)?; + let mut times = Vec::new(); + for _ in 0..25 { + let started = Instant::now(); + let posts = super::search_posts(conn, 1, term, 20, 0)?; + times.push(started.elapsed().as_micros()); + ensure!( + ids == posts.iter().map(|post| post.id).collect::>(), + "production FTS results changed" + ); + } + evidence.push( + serde_json::json!({"query":format!("production_fts_{term}"),"before":before_time, + "after":latencies(times.into_iter())}), + ); + } + Ok(evidence) +} + +/// Measure representative write cost before and after candidate indexes. +fn profile_writes(conn: &Connection, first: u32) -> Result { + let mut timings = Vec::new(); + for operation in first..first + 100 { + let started = Instant::now(); + conn.execute_batch("BEGIN IMMEDIATE")?; + write(conn, 0, operation)?; + super::commit_transaction(conn, "profile writes")?; + timings.push(started.elapsed().as_micros()); + } + Ok(latencies(timings.into_iter())) +} diff --git a/src/handlers/admin/backup/create.rs b/src/handlers/admin/backup/create.rs index 96a06a0e..e2824c17 100644 --- a/src/handlers/admin/backup/create.rs +++ b/src/handlers/admin/backup/create.rs @@ -35,6 +35,7 @@ pub(in crate::server) fn create_full_backup_to_server( if let Some(session_id) = session_id { require_admin_session_sid(&conn, Some(session_id))?; } + drop(conn); let uploads_base = Path::new(&CONFIG.upload_dir); let global_favicon_dir = crate::favicon::global_backup_source_dir(); let mut tor_hidden_service_keys_dir = if include_tor_hidden_service_keys { @@ -107,9 +108,20 @@ pub(in crate::server) fn create_full_backup_to_server( .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Backup DB path non-UTF-8")))? .replace('\'', "''"); + let conn = pool.get()?; + if let Some(session_id) = session_id { + require_admin_session_sid(&conn, Some(session_id))?; + } conn.execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) .map_err(|error| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {error}")))?; + drop(conn); restrict_backup_file(&db_snapshot_path)?; + // Export metadata from the same committed snapshot shipped in the backup; + // file copying and hashing no longer occupy a live pooled connection. + let conn = rusqlite::Connection::open_with_flags( + &db_snapshot_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + )?; let db_snapshot_size = std::fs::metadata(&db_snapshot_path) .map(|metadata| metadata.len()) .map_err(|error| AppError::Internal(anyhow::anyhow!("Stat DB snapshot: {error}")))?; @@ -311,7 +323,6 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve job_id: u64, reason: &str, ) -> Result { - let conn = pool.get()?; let backup_id = storage::build_backup_id(storage::BackupScope::PreMaintenance, "pre-repair-db"); let root_dir = storage::create_backup_root(&backup_id)?; let db_dir = root_dir.join("db"); @@ -329,9 +340,15 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Backup DB path non-UTF-8")))? .replace('\'', "''"); + let conn = pool.get()?; conn.execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) .map_err(|error| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {error}")))?; + drop(conn); restrict_backup_file(&db_snapshot_path)?; + let conn = rusqlite::Connection::open_with_flags( + &db_snapshot_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + )?; let db_snapshot_size = std::fs::metadata(&db_snapshot_path) .map(|metadata| metadata.len()) .map_err(|error| AppError::Internal(anyhow::anyhow!("Stat DB snapshot: {error}")))?; diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index 17b36d4e..0395fb3a 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -40,7 +40,7 @@ pub(in crate::server) async fn create_thread( &state, &board_short, admin_session_id.clone(), - access_cookie, + access_cookie.clone(), BoardAccessRequirement::Post, format!("/{board_short}"), ) @@ -135,9 +135,8 @@ pub(in crate::server) async fn create_thread( // dropped. Keep the permit inside this closure so a disconnected // request cannot release the media gate while parsing continues. let _media_upload_guard = media_upload_guard; - let conn = pool.get()?; posting::submit_post( - &conn, + &pool, &job_queue, posting::SubmitPostCommand { mode: posting::SubmitPostMode::NewThread { @@ -150,6 +149,7 @@ pub(in crate::server) async fn create_thread( identity_key, cookie_secret: CONFIG.cookie_secret.clone(), admin_session_id, + access_cookie, ban_csrf_token, submission_token, name, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index a46097ce..ea967870 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1265,7 +1265,7 @@ use crate::models::Board; pub(crate) fn process_primary_upload( file_data: Option<(TempUpload, String)>, board: &Board, - conn: &rusqlite::Connection, + mut lookup: impl FnMut(&str) -> Result>, upload_dir: &str, save_root: &str, thumb_size: u32, @@ -1363,7 +1363,7 @@ pub(crate) fn process_primary_upload( // record_file_hash uses INSERT OR REPLACE, so the cache entry is // automatically refreshed to point at the newly saved files. let hash = sha256_file_hex(upload.temp_file.path())?; - if let Some(cached) = crate::db::find_file_by_hash(conn, &hash)? { + if let Some(cached) = lookup(&hash)? { let same_board_cache = cached_paths_belong_to_board(&cached, &board.short_name); let file_ok = std::path::Path::new(upload_dir) .join(&cached.file_path) @@ -1488,7 +1488,7 @@ pub(crate) fn process_audio_first_uploads( image_file_data: Option<(TempUpload, String)>, fallback_file_data: Option<(TempUpload, String)>, board: &Board, - conn: &rusqlite::Connection, + mut lookup: impl FnMut(&str) -> Result>, upload_dir: &str, save_root_str: &str, thumb_size: u32, @@ -1505,11 +1505,11 @@ pub(crate) fn process_audio_first_uploads( let allow_any_files = crate::config::CONFIG.enable_any_file_uploads_feature && board.allow_any_files; let has_audio_or_image_upload = audio_file_data.is_some() || image_file_data.is_some(); - let save_primary = |file_data| { + let mut save_primary = |file_data| { process_primary_upload( file_data, board, - conn, + &mut lookup, upload_dir, save_root_str, thumb_size, @@ -1583,7 +1583,7 @@ fn sha256_file_hex(path: &std::path::Path) -> Result { /// post. Shared by `create_thread` and `post_reply`. pub(crate) fn enqueue_post_jobs( job_queue: &JobQueue, - conn: &rusqlite::Connection, + pool: &crate::db::DbPool, post_id: i64, ip_hash: &str, body_len: usize, @@ -1609,7 +1609,9 @@ pub(crate) fn enqueue_post_jobs( | crate::models::MediaType::Other => None, }; if let Some(j) = job { - match job_queue.enqueue_media(conn, &j) { + let conn = pool.get()?; + let enqueued = job_queue.enqueue_media(&conn, &j); + match enqueued { Ok(crate::workers::EnqueueOutcome::Enqueued(job_id)) => tracing::debug!( target: "workers", post_id, @@ -2488,7 +2490,7 @@ trailer << /Root 1 0 R >> None, Some(other), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), boards_dir .path() .to_str() @@ -2550,7 +2552,7 @@ trailer << /Root 1 0 R >> let result = super::process_primary_upload( Some(upload), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() @@ -2615,7 +2617,7 @@ trailer << /Root 1 0 R >> let (uploaded, primary_hash) = super::process_primary_upload( Some(temp_upload("doc.pdf", pdf)?), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() @@ -2655,7 +2657,7 @@ trailer << /Root 1 0 R >> let result = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() @@ -2697,7 +2699,7 @@ trailer << /Root 1 0 R >> let result = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() @@ -2735,7 +2737,7 @@ trailer << /Root 1 0 R >> let result = super::process_primary_upload( Some(temp_upload("not-really.pdf", b"plain text")?), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() @@ -2777,7 +2779,7 @@ trailer << /Root 1 0 R >> let (uploaded, _) = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - &conn, + |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), uploads_dir .path() .to_str() diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index 971cc4a7..2578aa8b 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -36,6 +36,7 @@ pub(super) struct SubmitPostCommand { pub identity_key: String, pub cookie_secret: String, pub admin_session_id: Option, + pub access_cookie: Option, pub ban_csrf_token: String, pub submission_token: String, pub name: String, @@ -107,11 +108,7 @@ struct ProcessedUploads { } impl ProcessedUploads { - pub(crate) fn rollback_new_files( - &self, - conn: &rusqlite::Connection, - upload_dir: &str, - ) -> Result<()> { + pub(crate) fn rollback_new_files(&self, pool: &db::DbPool, upload_dir: &str) -> Result<()> { if let Some(pending) = self.pending_finalize.as_ref() { let stage_dir = std::path::Path::new(&pending.payload.stage_dir); if stage_dir.exists() { @@ -137,7 +134,9 @@ impl ProcessedUploads { } match crate::utils::files::delete_file_checked(upload_dir, &primary.file_path) { Ok(()) => { - if let Err(error) = db::delete_file_hash_by_path(conn, &primary.file_path) { + if let Err(error) = + db::delete_file_hash_by_path(&*pool.get()?, &primary.file_path) + { return Err(AppError::Internal(error)); } } @@ -310,17 +309,13 @@ fn build_pending_upload_op( })) } -fn finalize_pending_uploads( - conn: &rusqlite::Connection, - upload_dir: &str, - uploads: &ProcessedUploads, -) { +fn finalize_pending_uploads(pool: &db::DbPool, upload_dir: &str, uploads: &ProcessedUploads) { let Some(pending) = uploads.pending_finalize.as_ref() else { return; }; - match crate::pending_fs::finalize_upload_payload_for_op( - conn, + match crate::pending_fs::finalize_upload_payload_with_pool( + pool, upload_dir, Some(&pending.op_id), &pending.payload, @@ -336,6 +331,97 @@ fn finalize_pending_uploads( } } +/// Clean uncommitted staged files if the final pool checkout fails. +fn checkout_post_connection( + pool: &db::DbPool, + uploads: &ProcessedUploads, + upload_dir: &str, +) -> Result> { + match pool.get() { + Ok(conn) => Ok(conn), + Err(error) => { + uploads.rollback_new_files(pool, upload_dir)?; + Err(error.into()) + } + } +} + +/// Preserve a transaction's typed public rejection. +fn post_creation_error(error: anyhow::Error) -> AppError { + match error.downcast::() { + Ok(error) => error, + Err(error) => error.into(), + } +} + +/// Check mutable request policy while the post transaction owns the write lock. +fn revalidate_posting_policy( + conn: &rusqlite::Connection, + prepared: &Board, + prepared_filters: &[(String, String)], + admin_session_id: Option<&str>, + access_cookie: Option<&str>, + ip_hash: &str, + ban_csrf_token: &str, +) -> Result<()> { + let context = crate::handlers::board::load_board_access_context( + conn, + &prepared.short_name, + admin_session_id, + access_cookie, + )?; + let current = &context.board; + if current.id != prepared.id || !context.can_post { + return Err(AppError::Forbidden( + "Board posting access changed. Reload the board.".into(), + )); + } + crate::handlers::board::ensure_actor_not_banned(conn, ip_hash, ban_csrf_token.to_owned())?; + // These settings determined media acceptance and body preparation. Refuse + // stale preparation rather than publishing under a different policy. + let media_policy = |board: &Board| { + ( + ( + board.allow_images, + board.allow_video, + board.allow_audio, + board.allow_pdf, + board.allow_any_files, + ), + ( + board.max_image_size, + board.max_video_size, + board.max_audio_size, + board.max_pdf_size, + ), + ( + board.allow_tripcodes, + board.allow_captcha, + board.collapse_greentext, + ), + ) + }; + if media_policy(current) != media_policy(prepared) + || load_word_filters(conn)? != prepared_filters + { + return Err(AppError::Conflict( + "Board posting settings changed. Reload the board and try again.".into(), + )); + } + if !context.is_admin && current.post_cooldown_secs > 0 { + if let Some(seconds) = db::get_seconds_since_last_post(conn, current.id, ip_hash)? { + let remaining = current.post_cooldown_secs.saturating_sub(seconds); + if remaining > 0 { + return Err(AppError::BadRequest(format!( + "Please wait {remaining} more second{} before posting again.", + if remaining == 1 { "" } else { "s" } + ))); + } + } + } + Ok(()) +} + pub(super) fn is_admin_session( conn: &rusqlite::Connection, admin_session_id: Option<&str>, @@ -389,7 +475,7 @@ fn process_uploads( file_data: Option<(crate::handlers::TempUpload, String)>, audio_file_data: Option<(crate::handlers::TempUpload, String)>, board: &Board, - conn: &rusqlite::Connection, + pool: &db::DbPool, config: &UploadConfig<'_>, ) -> Result { let stage_root = @@ -409,7 +495,10 @@ fn process_uploads( image_file_data, file_data, board, - conn, + |hash| { + let conn = pool.get()?; + Ok(db::find_file_by_hash(&conn, hash)?) + }, config.upload_dir, save_root_str, config.thumb_size, @@ -428,17 +517,24 @@ fn process_uploads( } }; - let pending_finalize = stage_root + let pending_payload = stage_root .as_ref() .map(|stage_dir| { build_upload_finalize_payload(stage_dir, primary.as_mut(), audio.as_mut(), primary_hash) }) - .transpose()? - .flatten() - .map(|payload| PendingUploadFinalize { - op_id: uuid::Uuid::new_v4().to_string(), - payload, - }); + .transpose(); + let pending_finalize = match pending_payload { + Ok(payload) => payload, + Err(error) => { + cleanup_unused_upload_stage(stage_root.as_deref()); + return Err(error); + } + } + .flatten() + .map(|payload| PendingUploadFinalize { + op_id: uuid::Uuid::new_v4().to_string(), + payload, + }); if pending_finalize.is_none() { cleanup_unused_upload_stage(stage_root.as_deref()); @@ -495,6 +591,15 @@ fn build_new_post( } } +/// Submit with independently scoped preparation, persistence and promotion. +pub(super) fn submit_post( + pool: &db::DbPool, + job_queue: &crate::workers::JobQueue, + command: SubmitPostCommand, +) -> Result { + submit_post_with_preparation(pool, job_queue, command, || Ok(())) +} + #[expect( clippy::cognitive_complexity, reason = "post validation and transactional creation share one consistency boundary" @@ -503,10 +608,12 @@ fn build_new_post( clippy::too_many_lines, reason = "validation, transactional insertion, attachment updates, and job enqueueing share one boundary" )] -pub(super) fn submit_post( - conn: &rusqlite::Connection, +/// The preparation callback permits deterministic interleaving in regression tests. +fn submit_post_with_preparation( + pool: &db::DbPool, job_queue: &crate::workers::JobQueue, command: SubmitPostCommand, + prepare: impl FnOnce() -> Result<()>, ) -> Result { let SubmitPostCommand { mode, @@ -514,6 +621,7 @@ pub(super) fn submit_post( identity_key, cookie_secret, admin_session_id, + access_cookie, ban_csrf_token, submission_token, name, @@ -529,7 +637,8 @@ pub(super) fn submit_post( ffmpeg_available, } = command; - let board = db::get_board_by_short(conn, &board_short)? + let conn = pool.get()?; + let board = db::get_board_by_short(&conn, &board_short)? .ok_or_else(|| AppError::NotFound(format!("Board /{board_short}/ not found")))?; let effective_max_image_size = board.max_image_size_bytes(); let effective_max_video_size = board.max_video_size_bytes(); @@ -538,7 +647,7 @@ pub(super) fn submit_post( let reply_context = match &mode { SubmitPostMode::Reply { thread_id, sage } => { - let thread = db::get_thread(conn, *thread_id)? + let thread = db::get_thread(&conn, *thread_id)? .ok_or_else(|| AppError::NotFound("Thread not found.".into()))?; if thread.board_id != board.id { @@ -551,20 +660,20 @@ pub(super) fn submit_post( return Err(AppError::Forbidden("This thread is archived.".into())); } - Some((*thread_id, *sage, thread.reply_count)) + Some((*thread_id, *sage)) } SubmitPostMode::NewThread { .. } => None, }; let ip_hash = hash_ip(&identity_key, &cookie_secret); - crate::handlers::board::ensure_actor_not_banned(conn, &ip_hash, ban_csrf_token)?; - if let Some(existing) = db::get_post_submission(conn, &submission_token, &ip_hash, board.id)? { - return existing_submission_result(conn, board.short_name, existing); + crate::handlers::board::ensure_actor_not_banned(&conn, &ip_hash, ban_csrf_token.clone())?; + if let Some(existing) = db::get_post_submission(&conn, &submission_token, &ip_hash, board.id)? { + return existing_submission_result(&conn, board.short_name, existing); } - let is_admin = is_admin_session(conn, admin_session_id.as_deref()); + let is_admin = is_admin_session(&conn, admin_session_id.as_deref()); if board.post_cooldown_secs > 0 && !is_admin { - let elapsed = db::get_seconds_since_last_post(conn, board.id, &ip_hash)?; + let elapsed = db::get_seconds_since_last_post(&conn, board.id, &ip_hash)?; if let Some(secs) = elapsed { let remaining = board.post_cooldown_secs.saturating_sub(secs); if remaining > 0 { @@ -581,7 +690,9 @@ pub(super) fn submit_post( .map_err(|error| AppError::BadRequest(error.user_message().to_owned()))?; } - let filters = load_word_filters(conn)?; + let filters = load_word_filters(&conn)?; + drop(conn); + prepare()?; let (name, tripcode) = resolve_post_identity(&name, board.allow_tripcodes); let board_allows_media = board.allow_images || board.allow_video @@ -602,7 +713,7 @@ pub(super) fn submit_post( file_data, audio_file_data, &board, - conn, + pool, &UploadConfig { upload_dir: &upload_dir, thumb_size, @@ -614,7 +725,13 @@ pub(super) fn submit_post( }, )?; let deletion_token = resolve_deletion_token(&deletion_token); - let pending_upload_op = build_pending_upload_op(&uploads)?; + let pending_upload_op = match build_pending_upload_op(&uploads) { + Ok(op) => op, + Err(error) => { + uploads.rollback_new_files(pool, &upload_dir)?; + return Err(error); + } + }; let deduplicated_paths: Vec<&str> = uploads .primary .iter() @@ -622,6 +739,18 @@ pub(super) fn submit_post( .map(|upload| upload.file_path.as_str()) .collect(); + let validate = |conn: &rusqlite::Connection| { + revalidate_posting_policy( + conn, + &board, + &filters, + admin_session_id.as_deref(), + access_cookie.as_deref(), + &ip_hash, + &ban_csrf_token, + ) + .map_err(anyhow::Error::new) + }; let (post_id, thread_id, redirect_url, prune_board_id) = match mode { SubmitPostMode::NewThread { subject, @@ -653,19 +782,19 @@ pub(super) fn submit_post( None } else { if q.is_empty() { - uploads.rollback_new_files(conn, &upload_dir)?; + uploads.rollback_new_files(pool, &upload_dir)?; return Err(AppError::BadRequest( "Polls need a question and at least two options.".into(), )); } if valid_opts.len() < 2 { - uploads.rollback_new_files(conn, &upload_dir)?; + uploads.rollback_new_files(pool, &upload_dir)?; return Err(AppError::BadRequest( "Polls need a question and at least two options.".into(), )); } let Some(secs) = poll_duration_secs else { - uploads.rollback_new_files(conn, &upload_dir)?; + uploads.rollback_new_files(pool, &upload_dir)?; return Err(AppError::BadRequest( "A duration is required when creating a poll.".into(), )); @@ -678,28 +807,36 @@ pub(super) fn submit_post( expires_at, }) }; + let conn = checkout_post_connection(pool, &uploads, &upload_dir)?; let create_result = db::threads::create_thread_submission( - conn, + &conn, board.id, subject.as_deref(), &new_post, &submission_token, poll_insert.as_ref(), - db::threads::PostFilesystemCommit::new( + db::threads::PostFilesystemCommit::new_with_validation( pending_upload_op.as_ref(), &deduplicated_paths, true, + validate, ), ); let (thread_id, post_id, _) = match create_result { - Ok(db::threads::PostCreationOutcome::Created(ids)) => ids, + Ok(db::threads::PostCreationOutcome::Created(ids)) => { + drop(conn); + ids + } Ok(db::threads::PostCreationOutcome::Replayed(existing)) => { - uploads.rollback_new_files(conn, &upload_dir)?; - return existing_submission_result(conn, board.short_name, existing); + let result = existing_submission_result(&conn, board.short_name, existing); + drop(conn); + uploads.rollback_new_files(pool, &upload_dir)?; + return result; } Err(error) => { - uploads.rollback_new_files(conn, &upload_dir)?; - return Err(error.into()); + drop(conn); + uploads.rollback_new_files(pool, &upload_dir)?; + return Err(post_creation_error(error)); } }; ( @@ -710,9 +847,9 @@ pub(super) fn submit_post( ) } SubmitPostMode::Reply { .. } => { - let (thread_id, sage, reply_count) = reply_context + let (thread_id, sage) = reply_context .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Missing reply context")))?; - let should_bump = !sage && reply_count < board.bump_limit; + let should_bump = !sage; let new_post = build_new_post( thread_id, board.id, @@ -726,28 +863,36 @@ pub(super) fn submit_post( deletion_token.clone(), false, ); + let conn = checkout_post_connection(pool, &uploads, &upload_dir)?; let post_id = match db::threads::create_reply_submission( - conn, + &conn, &new_post, &submission_token, should_bump, - db::threads::PostFilesystemCommit::new( + db::threads::PostFilesystemCommit::new_with_validation( pending_upload_op.as_ref(), &deduplicated_paths, false, + validate, ), ) { - Ok(db::threads::PostCreationOutcome::Created(post_id)) => post_id, + Ok(db::threads::PostCreationOutcome::Created(post_id)) => { + drop(conn); + post_id + } Ok(db::threads::PostCreationOutcome::Replayed(existing)) => { - uploads.rollback_new_files(conn, &upload_dir)?; - return existing_submission_result(conn, board.short_name, existing); + let result = existing_submission_result(&conn, board.short_name, existing); + drop(conn); + uploads.rollback_new_files(pool, &upload_dir)?; + return result; } Err(error) => { - uploads.rollback_new_files(conn, &upload_dir)?; + drop(conn); + uploads.rollback_new_files(pool, &upload_dir)?; if let Some(closed) = error.downcast_ref::() { return Err(AppError::Forbidden(closed.message().to_owned())); } - return Err(error.into()); + return Err(post_creation_error(error)); } }; ( @@ -759,10 +904,10 @@ pub(super) fn submit_post( } }; - finalize_pending_uploads(conn, &upload_dir, &uploads); + finalize_pending_uploads(pool, &upload_dir, &uploads); crate::handlers::enqueue_post_jobs( job_queue, - conn, + pool, post_id, &ip_hash, body_text.len(), @@ -770,7 +915,8 @@ pub(super) fn submit_post( &board.short_name, )?; if let Some(prune_board_id) = prune_board_id { - if let Err(error) = job_queue.notify_persisted_thread_prune(conn) { + let conn = pool.get()?; + if let Err(error) = job_queue.notify_persisted_thread_prune(&conn) { tracing::warn!( target: "workers", board = %board.short_name, @@ -788,7 +934,7 @@ pub(super) fn submit_post( } else { tracing::info!(target: "board", post_id = post_id, thread_id = thread_id, board = %board.short_name, "Reply posted"); } - if let Err(error) = crate::media::prune::run_configured_prune(conn, &upload_dir) { + if let Err(error) = crate::media::prune::run_configured_prune_with_pool(pool, &upload_dir) { tracing::warn!( target: "media_prune", post_id, @@ -797,7 +943,8 @@ pub(super) fn submit_post( ); } - let stored_post = db::get_post(conn, post_id)? + let conn = pool.get()?; + let stored_post = db::get_post(&conn, post_id)? .ok_or_else(|| AppError::NotFound("Posted row not found.".into()))?; Ok(SubmitPostResult { @@ -847,7 +994,7 @@ mod tests { }}; } - const TEST_BOARD: &str = "test"; + pub(super) const TEST_BOARD: &str = "test"; const TEST_COOKIE_SECRET: &str = "cookie-secret"; const TEST_IDENTITY_KEY: &str = "127.0.0.1"; @@ -882,7 +1029,7 @@ mod tests { } } - fn thread_command( + pub(super) fn thread_command( board_short: &str, submission_token: &str, body: &str, @@ -899,6 +1046,7 @@ mod tests { identity_key: TEST_IDENTITY_KEY.to_owned(), cookie_secret: TEST_COOKIE_SECRET.to_owned(), admin_session_id: None, + access_cookie: None, ban_csrf_token: "ban-csrf".to_owned(), submission_token: submission_token.to_owned(), name: "anon".to_owned(), @@ -935,6 +1083,7 @@ mod tests { identity_key: TEST_IDENTITY_KEY.to_owned(), cookie_secret: TEST_COOKIE_SECRET.to_owned(), admin_session_id: None, + access_cookie: None, ban_csrf_token: "ban-csrf".to_owned(), submission_token: submission_token.to_owned(), name: "anon".to_owned(), @@ -967,6 +1116,7 @@ mod tests { identity_key: TEST_IDENTITY_KEY.to_owned(), cookie_secret: TEST_COOKIE_SECRET.to_owned(), admin_session_id: None, + access_cookie: None, ban_csrf_token: "ban-csrf".to_owned(), submission_token: submission_token.to_owned(), name: "anon".to_owned(), @@ -983,7 +1133,7 @@ mod tests { } } - fn temp_upload(name: &str, bytes: &[u8]) -> Result<(TempUpload, String)> { + pub(super) fn temp_upload(name: &str, bytes: &[u8]) -> Result<(TempUpload, String)> { let temp_file = tempfile::Builder::new() .prefix("rustchan-posting-test-upload-") .tempfile() @@ -1068,8 +1218,7 @@ mod tests { let job_queue = std::sync::Arc::clone(&job_queue); handles.push(scope.spawn(move || { barrier.wait(); - let conn = pool.get()?; - submit_post(&conn, job_queue.as_ref(), command) + submit_post(&pool, job_queue.as_ref(), command) })); } @@ -1179,7 +1328,7 @@ mod tests { .context("failed to add ban")?; let error = match submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -1242,7 +1391,7 @@ mod tests { .context("failed to create thread")?; let error = match submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -1286,7 +1435,7 @@ mod tests { .context("failed to enable CAPTCHA")?; let error = match submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -1349,7 +1498,7 @@ mod tests { command.captcha_id = captcha_id.to_owned(); command.captcha_answer = "abc23".to_owned(); - let result = submit_post(&conn, state.job_queue.as_ref(), command) + let result = submit_post(&state.db, state.job_queue.as_ref(), command) .context("failed to submit post with valid CAPTCHA")?; assert_eq!(result.board_short, TEST_BOARD); @@ -1364,7 +1513,7 @@ mod tests { ); replay.captcha_id = captcha_id.to_owned(); replay.captcha_answer = "ABC23".to_owned(); - let error = match submit_post(&conn, state.job_queue.as_ref(), replay) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), replay) { Ok(result) => bail!( "expected captcha replay rejection, got {}", result.redirect_url @@ -1409,7 +1558,7 @@ mod tests { command.captcha_id = captcha_id.to_owned(); command.captcha_answer = "ABC23".to_owned(); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "expected expired captcha rejection, got {}", result.redirect_url @@ -1432,7 +1581,7 @@ mod tests { .context("failed to create board")?; let error = match submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command_with_poll( TEST_BOARD, @@ -1484,7 +1633,7 @@ mod tests { None, Some(bad_audio), &board, - &conn, + &state.db, &UploadConfig { upload_dir: upload_dir .path() @@ -1530,7 +1679,7 @@ mod tests { ); command.file_data = Some(temp_upload("cover.png", &one_pixel_png()?)?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "board-specific image cap should reject upload, got {}", result.redirect_url @@ -1568,7 +1717,7 @@ mod tests { ); command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes())?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "audio-disabled board should reject, got {}", result.redirect_url @@ -1615,7 +1764,7 @@ mod tests { command.file_data = Some(temp_upload("broken.aac", &malformed_aac_bytes()?)?); command.ffmpeg_available = true; - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!("malformed AAC should reject, got {}", result.redirect_url), Err(error) => error, }; @@ -1665,7 +1814,7 @@ mod tests { ); command.file_data = Some(temp_upload("tiny.mp4", &mp4_header_bytes())?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "video-disabled board should reject, got {}", result.redirect_url @@ -1711,7 +1860,7 @@ mod tests { ); command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes())?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "over-limit audio should reject, got {}", result.redirect_url @@ -1757,7 +1906,7 @@ mod tests { ); command.file_data = Some(temp_upload("tiny.mp4", &mp4_header_bytes())?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "over-limit video should reject, got {}", result.redirect_url @@ -1802,7 +1951,7 @@ mod tests { ); command.file_data = Some(temp_upload("cover.png", &one_pixel_png()?)?); - let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + let error = match submit_post(&state.db, state.job_queue.as_ref(), command) { Ok(result) => bail!( "poll validation should reject submission, got {}", result.redirect_url @@ -1852,7 +2001,7 @@ mod tests { ); command.file_data = Some(temp_upload("cover.png", &one_pixel_png()?)?); - let result = submit_post(&conn, state.job_queue.as_ref(), command) + let result = submit_post(&state.db, state.job_queue.as_ref(), command) .context("board-specific raised image cap should allow upload")?; assert_eq!(result.board_short, TEST_BOARD); @@ -1896,7 +2045,7 @@ mod tests { Some(temp_upload("same-but-renamed.jpg", &bytes)?), None, &board, - &conn, + &state.db, &UploadConfig { upload_dir: upload_dir .path() @@ -1984,7 +2133,7 @@ mod tests { Some(temp_upload(&format!("failure-{index}.png"), &png)?), None, &board, - &conn, + &state.db, &UploadConfig { upload_dir: upload_dir.path().to_str().context("UTF-8 upload root")?, thumb_size: 64, @@ -2039,7 +2188,7 @@ mod tests { .context("failed to create board")?; submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2062,7 +2211,7 @@ mod tests { .context("failed to age original post")?; let duplicate = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2130,7 +2279,7 @@ mod tests { .context("failed to create thread")?; submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2146,7 +2295,7 @@ mod tests { .context("failed to create first reply")?; let duplicate = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2417,7 +2566,7 @@ mod tests { .context("failed to create board")?; let first_thread = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2427,7 +2576,7 @@ mod tests { ), )?; let replayed_thread = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2482,7 +2631,7 @@ mod tests { None, )?; let first_reply = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2493,7 +2642,7 @@ mod tests { ), )?; let replayed_reply = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2535,7 +2684,7 @@ mod tests { .get() .context("failed to get rollback database connection")?; conn.execute_batch( - "CREATE TEMP TRIGGER fail_submission_post + "CREATE TRIGGER fail_submission_post BEFORE INSERT ON posts WHEN NEW.body IN ('force thread rollback', 'force reply rollback') BEGIN @@ -2543,7 +2692,7 @@ mod tests { END;", )?; let failed_thread = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2554,7 +2703,7 @@ mod tests { ); assert!(failed_thread.is_err()); let failed_reply = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2575,7 +2724,7 @@ mod tests { conn.execute_batch("DROP TRIGGER fail_submission_post")?; let corrected_thread = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), thread_command( TEST_BOARD, @@ -2585,7 +2734,7 @@ mod tests { ), )?; let corrected_reply = submit_post( - &conn, + &state.db, state.job_queue.as_ref(), reply_command( TEST_BOARD, @@ -2620,4 +2769,245 @@ mod tests { assert_database_integrity(&conn, upload_dir.path())?; Ok(()) } + + /// Replace the test state's pool with one connection to expose nested borrowing. + fn single_connection_state() -> Result { + let mut state = crate::test_support::app_state(); + let path = { + let conn = state.db.get()?; + conn.path().context("test database has no path")?.to_owned() + }; + let manager = r2d2_sqlite::SqliteConnectionManager::file(path).with_init(|conn| { + conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;") + }); + state.db = r2d2::Pool::builder() + .max_size(1) + .connection_timeout(std::time::Duration::from_secs(1)) + .build(manager)?; + state.job_queue = std::sync::Arc::new(crate::workers::JobQueue::new(state.db.clone())); + Ok(state) + } + + #[test] + fn independent_preparation_releases_the_only_pool_connection() -> Result<()> { + let state = single_connection_state()?; + let uploads = tempfile::tempdir()?; + crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false)?; + let command = thread_command( + TEST_BOARD, + "single-pool-post", + "body", + uploads.path().to_str().context("upload path")?, + ); + let result = + super::submit_post_with_preparation(&state.db, &state.job_queue, command, || { + let conn = state + .db + .try_get() + .context("preparation must release the sole connection")?; + crate::db::database_ready_probe(&conn)?; + Ok(()) + })?; + let conn = state + .db + .try_get() + .context("submission must return its connection")?; + assert!(crate::db::get_post(&conn, result.post_id)?.is_some()); + assert!( + crate::db::pending_job_count(&conn)? > 0, + "spam scheduling must not exhaust a one-connection pool" + ); + Ok(()) + } + + #[test] + fn reply_rechecks_thread_state_after_independent_preparation() -> Result<()> { + for archived in [false, true] { + let state = single_connection_state()?; + let uploads = tempfile::tempdir()?; + let conn = state.db.get()?; + let board = crate::db::create_board(&conn, TEST_BOARD, "Test", "", false)?; + let (thread, _, _) = crate::db::create_thread_with_optional_poll( + &conn, + board, + None, + &sample_post(board, 0, "op", true, None), + "", + None, + None, + )?; + drop(conn); + let command = reply_command( + TEST_BOARD, + thread, + "state-race", + "reply", + uploads.path().to_str().context("upload path")?, + ); + let result = + super::submit_post_with_preparation(&state.db, &state.job_queue, command, || { + let conn = state.db.try_get().context("preparation held the pool")?; + if archived { + crate::db::set_thread_archived(&conn, thread, true)?; + } else { + crate::db::set_thread_locked(&conn, thread, true)?; + } + Ok(()) + }); + assert!(matches!(result, Err(AppError::Forbidden(_)))); + let conn = state.db.get()?; + assert_eq!(crate::db::get_posts_for_thread(&conn, thread)?.len(), 1); + assert_eq!( + crate::db::get_thread(&conn, thread)? + .context("thread")? + .reply_count, + 0 + ); + assert!(conn.is_autocommit()); + } + Ok(()) + } + + #[test] + fn posting_rechecks_access_media_and_bans_at_commit() -> Result<()> { + for mutation in ["access", "media", "ban", "captcha", "cooldown", "filters"] { + let state = single_connection_state()?; + let uploads = tempfile::tempdir()?; + crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false)?; + let command = thread_command( + TEST_BOARD, + "policy-race", + "body", + uploads.path().to_str().context("upload path")?, + ); + let result = super::submit_post_with_preparation( + &state.db, + &state.job_queue, + command, + || { + let conn = state.db.try_get().context("preparation held the pool")?; + match mutation { + "access" => { + conn.execute("UPDATE boards SET access_mode='post_password',access_password_hash='changed'", [])?; + } + "media" => { + conn.execute("UPDATE boards SET allow_images=0", [])?; + } + "captcha" => { + conn.execute("UPDATE boards SET allow_captcha=1", [])?; + } + "filters" => { + conn.execute("INSERT INTO word_filters(pattern,replacement) VALUES('body','filtered')", [])?; + } + "cooldown" => { + let board = crate::db::get_board_by_short(&conn, TEST_BOARD)? + .context("board")?; + conn.execute("UPDATE boards SET post_cooldown_secs=60", [])?; + crate::db::create_thread_with_optional_poll( + &conn, + board.id, + None, + &sample_post( + board.id, + 0, + "other post", + true, + Some(crate::utils::crypto::hash_ip( + TEST_IDENTITY_KEY, + TEST_COOKIE_SECRET, + )), + ), + "", + None, + None, + )?; + } + _ => { + crate::db::add_ban( + &conn, + &crate::utils::crypto::hash_ip( + TEST_IDENTITY_KEY, + TEST_COOKIE_SECRET, + ), + "new ban", + None, + )?; + } + } + Ok(()) + }, + ); + assert!( + result.is_err(), + "concurrent policy change {mutation} must reject the write" + ); + let conn = state.db.get()?; + assert!(conn.is_autocommit()); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM post_submissions WHERE submission_token='policy-race'", + [], + |r| r.get(0), + )?; + assert_eq!(count, 0); + } + Ok(()) + } + + #[test] + fn concurrent_cooldown_submissions_cannot_both_commit() -> Result<()> { + let state = crate::test_support::app_state(); + let uploads = tempfile::tempdir()?; + let conn = state.db.get()?; + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false)?; + conn.execute("UPDATE boards SET post_cooldown_secs=60", [])?; + drop(conn); + let barrier = std::sync::Barrier::new(2); + let results = std::thread::scope(|scope| -> Result> { + let mut handles = Vec::new(); + for number in 0..2 { + let command = thread_command( + TEST_BOARD, + &format!("cooldown-{number}"), + "body", + uploads.path().to_str().context("upload path")?, + ); + let state = &state; + let barrier = &barrier; + handles.push(scope.spawn(move || { + super::submit_post_with_preparation( + &state.db, + &state.job_queue, + command, + || { + barrier.wait(); + Ok(()) + }, + ) + })); + } + handles + .into_iter() + .map(|handle| { + handle + .join() + .map_err(|_| anyhow::anyhow!("posting worker panicked")) + }) + .collect() + })?; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(AppError::BadRequest(_)))) + .count(), + 1 + ); + let conn = state.db.get()?; + crate::db::verify_database_schema(&conn)?; + Ok(()) + } } + +#[cfg(test)] +/// Opt-in posting/media connection-lifetime measurements. +mod performance; diff --git a/src/handlers/posting/performance.rs b/src/handlers/posting/performance.rs new file mode 100644 index 00000000..9efc6a29 --- /dev/null +++ b/src/handlers/posting/performance.rs @@ -0,0 +1,200 @@ +//! Explicit actual-media benchmark for pooled connection ownership. + +use super::tests::{temp_upload, thread_command, TEST_BOARD}; +use super::{submit_post, SubmitPostCommand, SubmitPostResult}; +use anyhow::{Context as _, Result}; + +/// Event subscriber used only by the explicit posting benchmark. +#[derive(Debug, Clone, Default)] +struct PostingPoolProbe { + /// Connection hold durations on producer threads. + holds: std::sync::Arc>>, + /// Checkout durations on reader threads. + waits: std::sync::Arc>>, + /// Checkout timeouts across all benchmark threads. + timeouts: std::sync::Arc, +} + +impl r2d2::HandleEvent for PostingPoolProbe { + fn handle_checkin(&self, event: r2d2::event::CheckinEvent) { + if std::thread::current().name() == Some("db-post-benchmark") { + self.holds.lock().push(event.duration().as_micros()); + } + } + fn handle_checkout(&self, event: r2d2::event::CheckoutEvent) { + if std::thread::current().name() == Some("db-read-benchmark") { + self.waits.lock().push(event.duration().as_micros()); + } + } + fn handle_timeout(&self, _event: r2d2::event::TimeoutEvent) { + self.timeouts + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Summarize an elapsed-time series in microseconds. +fn posting_latencies(mut values: Vec) -> serde_json::Value { + values.sort_unstable(); + let percentile = |percent: usize| { + values + .get((values.len() * percent).div_ceil(100).saturating_sub(1)) + .copied() + .unwrap_or(0) + }; + serde_json::json!({"p50_us":percentile(50),"p95_us":percentile(95),"p99_us":percentile(99), + "max_us":values.last().copied().unwrap_or(0),"samples":values.len()}) +} + +/// Production entry point shared by the current and archived-baseline harness. +fn benchmark_submit( + pool: &crate::db::DbPool, + queue: &crate::workers::JobQueue, + command: SubmitPostCommand, +) -> crate::error::Result { + submit_post(pool, queue, command) +} + +/// Run actual PNG submissions alongside pooled reads; preparation is excluded from timing. +fn media_posting_case(size: u32, synchronous: &'static str) -> Result { + let mut state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir()?; + let path = { + let conn = state.db.get()?; + conn.path().context("DB path")?.to_owned() + }; + let probe = PostingPoolProbe::default(); + let manager = r2d2_sqlite::SqliteConnectionManager::file(path).with_init(move |conn| { + conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;", + )?; + conn.pragma_update(None, "synchronous", synchronous) + }); + state.db = r2d2::Pool::builder() + .max_size(size) + .min_idle(Some(size)) + .connection_timeout(std::time::Duration::from_secs(1)) + .event_handler(Box::new(probe.clone())) + .build(manager)?; + state.job_queue = std::sync::Arc::new(crate::workers::JobQueue::new(state.db.clone())); + crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false)?; + let mut commands = Vec::new(); + for number in 0_u8..8 { + let pixels = image::RgbImage::from_fn(768, 768, |x, y| { + image::Rgb([ + u8::try_from(x % 256).unwrap_or(0), + u8::try_from(y % 256).unwrap_or(0), + number, + ]) + }); + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(pixels).write_to(&mut bytes, image::ImageFormat::Png)?; + let (upload, name) = temp_upload(&format!("bench-{number}.png"), bytes.get_ref())?; + let mut command = thread_command( + TEST_BOARD, + &format!("media-bench-{number}"), + "media body", + upload_dir.path().to_str().context("uploads path")?, + ); + command.file_data = Some((upload, name)); + commands.push(command); + } + let mut result = run_media_posting_case(&state, commands, &probe, size)?; + result + .as_object_mut() + .context("media benchmark object")? + .insert("synchronous".into(), serde_json::json!(synchronous)); + Ok(result) +} + +/// Coordinate measured readers/producers without background network activity. +fn run_media_posting_case( + state: &crate::middleware::AppState, + commands: Vec, + probe: &PostingPoolProbe, + size: u32, +) -> Result { + let barrier = std::sync::Barrier::new(6); + let remaining = std::sync::atomic::AtomicU64::new(2); + let post_times = parking_lot::Mutex::new(Vec::new()); + let reads = std::sync::atomic::AtomicU64::new(0); + let started = std::time::Instant::now(); + std::thread::scope(|scope| -> Result<()> { + let mut handles = Vec::new(); + let mut commands = commands.into_iter(); + for _ in 0..2 { + let batch: Vec<_> = commands.by_ref().take(4).collect(); + let barrier = &barrier; + let remaining = &remaining; + let post_times = &post_times; + handles.push( + std::thread::Builder::new() + .name("db-post-benchmark".into()) + .spawn_scoped(scope, move || -> Result<()> { + barrier.wait(); + let result = (|| -> Result<()> { + for command in batch { + let started = std::time::Instant::now(); + benchmark_submit(&state.db, &state.job_queue, command)?; + post_times.lock().push(started.elapsed().as_micros()); + } + Ok(()) + })(); + remaining.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + result + })?, + ); + } + for _ in 0..4 { + let barrier = &barrier; + let remaining = &remaining; + let reads = &reads; + handles.push( + std::thread::Builder::new() + .name("db-read-benchmark".into()) + .spawn_scoped(scope, move || -> Result<()> { + barrier.wait(); + while remaining.load(std::sync::atomic::Ordering::Relaxed) > 0 { + if let Ok(conn) = state.db.get() { + crate::db::database_ready_probe(&conn)?; + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + Ok(()) + })?, + ); + } + for handle in handles { + handle + .join() + .map_err(|_| anyhow::anyhow!("media benchmark worker panicked"))??; + } + Ok(()) + })?; + let elapsed = started.elapsed(); + let conn = state.db.get()?; + crate::db::verify_database_schema(&conn)?; + let pending = crate::db::list_pending_fs_ops(&conn)?.len(); + let posts: i64 = conn.query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0))?; + Ok( + serde_json::json!({"pool_size":size,"posts":posts,"reads":reads.load(std::sync::atomic::Ordering::Relaxed), + "seconds":elapsed.as_secs_f64(),"posting":posting_latencies(post_times.into_inner()), + "posting_connection_holds":posting_latencies(probe.holds.lock().clone()), + "reader_checkout":posting_latencies(probe.waits.lock().clone()), + "pool_timeouts":probe.timeouts.load(std::sync::atomic::Ordering::Relaxed),"pending_fs_ops":pending}), + ) +} + +#[test] +#[ignore = "explicit actual-media/connection-lifetime benchmark; set RUSTCHAN_DB_POST_EVIDENCE"] +fn posting_connection_workload() -> Result<()> { + let output = std::env::var("RUSTCHAN_DB_POST_EVIDENCE")?; + let mut measurements = Vec::new(); + for synchronous in ["NORMAL", "FULL"] { + for size in [2, 4, 8] { + measurements.push(media_posting_case(size, synchronous)?); + } + } + std::fs::write(output, serde_json::to_vec_pretty(&measurements)?)?; + Ok(()) +} diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 2c3abefe..7e2e71c9 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -300,7 +300,7 @@ pub(in crate::server) async fn post_reply( &state, &board_short, admin_session_id.clone(), - access_cookie, + access_cookie.clone(), crate::handlers::board::BoardAccessRequirement::Post, format!("/{board_short}/thread/{thread_id}"), ) @@ -401,9 +401,8 @@ pub(in crate::server) async fn post_reply( // dropped. Keep the permit inside this closure so a disconnected // request cannot release the media gate while parsing continues. let _media_upload_guard = media_upload_guard; - let conn = pool.get()?; posting::submit_post( - &conn, + &pool, &job_queue, posting::SubmitPostCommand { mode: posting::SubmitPostMode::Reply { thread_id, sage }, @@ -411,6 +410,7 @@ pub(in crate::server) async fn post_reply( identity_key, cookie_secret: CONFIG.cookie_secret.clone(), admin_session_id, + access_cookie, ban_csrf_token, submission_token, name, diff --git a/src/media/prune.rs b/src/media/prune.rs index b48eb02f..28899bae 100644 --- a/src/media/prune.rs +++ b/src/media/prune.rs @@ -107,7 +107,40 @@ pub fn prune_to_limit( max_bytes: u64, ) -> Result { let upload_root = Path::new(upload_dir); - let mut candidates = load_candidates(conn, upload_root)?; + let candidates = load_candidates(conn, upload_root)?; + prune_candidates(candidates, upload_dir, max_bytes, || Ok(conn)) +} + +/// Run configured pruning with no pooled connection during candidate filesystem scans. +/// +/// # Errors +/// Returns an error if settings, candidate queries or checkout fail. +pub fn run_configured_prune_with_pool( + pool: &crate::db::DbPool, + upload_dir: &str, +) -> Result { + let (max_bytes, rows) = { + let conn = pool.get()?; + if !crate::db::get_media_auto_prune_enabled(&conn) { + return Ok(PruneReport::default()); + } + let max_bytes = crate::db::get_media_max_active_content_size_bytes(&conn); + if max_bytes == 0 { + return Ok(PruneReport::default()); + } + (max_bytes, load_candidate_rows(&conn)?) + }; + let candidates = validate_candidate_rows(rows, Path::new(upload_dir))?; + prune_candidates(candidates, upload_dir, max_bytes, || Ok(pool.get()?)) +} + +/// Process candidates, checking out only for the race-safe mutation boundary. +fn prune_candidates>( + mut candidates: Vec, + upload_dir: &str, + max_bytes: u64, + mut connection: impl FnMut() -> Result, +) -> Result { candidates.sort_by_key(|candidate| { ( candidate.created_at, @@ -131,7 +164,8 @@ pub fn prune_to_limit( if remaining <= max_bytes { break; } - let intent = match persist_prune_intent(conn, &candidate) { + let conn = connection()?; + let intent = match persist_prune_intent(&conn, &candidate) { Ok(intent) => intent, Err(error) => { report.skipped_files = report.skipped_files.saturating_add(1); @@ -145,7 +179,9 @@ pub fn prune_to_limit( } }; - match finalize_original_prune_payload(conn, upload_dir, &intent.id, &intent.payload) { + let finalized = + finalize_original_prune_payload(&conn, upload_dir, &intent.id, &intent.payload); + match finalized { Ok(finalized) => { remaining = remaining.saturating_sub(candidate.size); report.removed_files = report.removed_files.saturating_add(finalized.removed_files); @@ -178,11 +214,24 @@ pub fn prune_to_limit( } /// Load and validate all active original-media candidates. +/// Database-only candidate columns, collected before filesystem validation. +type CandidateRow = ( + i64, + i64, + String, + Option, + String, + Option, + Option, +); + +/// Collect and validate candidates for callers already owning a connection. fn load_candidates(conn: &rusqlite::Connection, upload_root: &Path) -> Result> { - // Resolve the upload root once; every candidate path is checked against it. - let canonical_root = upload_root - .canonicalize() - .with_context(|| format!("Canonicalize upload root {}", upload_root.display()))?; + validate_candidate_rows(load_candidate_rows(conn)?, upload_root) +} + +/// Collect candidate rows without performing filesystem access. +fn load_candidate_rows(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare_cached( "SELECT p.id, p.created_at, p.file_path, p.file_size, b.short_name, p.audio_file_path, p.audio_file_size @@ -214,6 +263,14 @@ fn load_candidates(conn: &rusqlite::Connection, upload_root: &Path) -> Result>>()?; + Ok(rows) +} + +/// Inspect untrusted paths and group references after releasing the pool borrow. +fn validate_candidate_rows(rows: Vec, upload_root: &Path) -> Result> { + let canonical_root = upload_root + .canonicalize() + .with_context(|| format!("Canonicalize upload root {}", upload_root.display()))?; let mut posts = Vec::new(); for (post_id, created_at, path, db_size, board_short, audio_path, audio_size) in rows { let mut paths = Vec::new(); diff --git a/src/pending_fs.rs b/src/pending_fs.rs index f1706fb7..dee696a1 100644 --- a/src/pending_fs.rs +++ b/src/pending_fs.rs @@ -1209,6 +1209,35 @@ pub(crate) fn finalize_upload_payload_for_op( upload_dir: &str, pending_op_id: Option<&str>, payload: &UploadFinalizePayload, +) -> Result<()> { + finalize_upload_payload_with(upload_dir, pending_op_id, payload, |missing_optional| { + commit_upload_finalize_metadata(conn, pending_op_id, payload, missing_optional) + }) +} + +/// Promote staged files while borrowing from the pool only for metadata commit. +/// The durable intent remains replayable until successful promotion and commit. +/// +/// # Errors +/// Returns an error if validation, promotion, checkout or metadata commit fails. +pub fn finalize_upload_payload_with_pool( + pool: &crate::db::DbPool, + upload_dir: &str, + pending_op_id: Option<&str>, + payload: &UploadFinalizePayload, +) -> Result<()> { + finalize_upload_payload_with(upload_dir, pending_op_id, payload, |missing_optional| { + let conn = pool.get()?; + commit_upload_finalize_metadata(&conn, pending_op_id, payload, missing_optional) + }) +} + +/// Share promotion/recovery semantics while allowing scoped database ownership. +fn finalize_upload_payload_with( + upload_dir: &str, + pending_op_id: Option<&str>, + payload: &UploadFinalizePayload, + commit: impl FnOnce(Option<&str>) -> Result>, ) -> Result<()> { let upload_root = Path::new(upload_dir); let stage_dir = Path::new(&payload.stage_dir); @@ -1256,8 +1285,7 @@ pub(crate) fn finalize_upload_payload_for_op( } } - let repaired_post_ids = - commit_upload_finalize_metadata(conn, pending_op_id, payload, missing_optional)?; + let repaired_post_ids = commit(missing_optional)?; if missing_optional.is_some() { tracing::warn!( @@ -1466,8 +1494,13 @@ fn cleanup_orphan_banner_files_in_dir( } /// Remove unreferenced banner files from global, home, and board storage roots. -fn cleanup_orphan_banner_files(conn: &rusqlite::Connection, upload_dir: &Path) -> Result<()> { - let referenced = referenced_banner_paths(conn)?; +fn cleanup_orphan_banner_files(pool: &crate::db::DbPool, upload_dir: &Path) -> Result<()> { + let referenced = { + let conn = pool + .get() + .context("Get DB connection for banner cleanup references failed")?; + referenced_banner_paths(&conn)? + }; cleanup_orphan_banner_files_in_dir(&crate::banner::global_banner_dir(), &referenced)?; cleanup_orphan_banner_files_in_dir(&crate::banner::home_banner_dir(), &referenced)?; if upload_dir.exists() { @@ -1554,9 +1587,6 @@ pub fn reconcile_pending_fs_ops(pool: &crate::db::DbPool, upload_dir: &str) -> R } } - let conn = pool - .get() - .context("Get DB connection for startup filesystem cleanup failed")?; cleanup_known_upload_temp_paths(Path::new(upload_dir))?; // Global runtime cleanup belongs only to the configured live upload tree. // Keeping it out of reconciliation calls for isolated restore/test roots @@ -1570,7 +1600,7 @@ pub fn reconcile_pending_fs_ops(pool: &crate::db::DbPool, upload_dir: &str) -> R &crate::banner::backup_source_dir(), &["restore-stage", "restore-old"], )?; - cleanup_orphan_banner_files(&conn, Path::new(upload_dir))?; + cleanup_orphan_banner_files(pool, Path::new(upload_dir))?; } Ok(()) @@ -1583,16 +1613,16 @@ fn apply_pending_fs_op( op: &crate::db::PendingFsOpRow, referenced_upload_stage_dirs: &mut std::collections::HashSet, ) -> Result<()> { + if op.kind == UPLOAD_FINALIZE_KIND { + let payload: UploadFinalizePayload = serde_json::from_str(&op.payload_json) + .with_context(|| format!("Parse upload_finalize payload for {}", op.id))?; + referenced_upload_stage_dirs.insert(payload.stage_dir.clone()); + return finalize_upload_payload_with_pool(pool, upload_dir, Some(&op.id), &payload); + } let conn = pool .get() .context("Get DB connection for pending_fs_op application failed")?; match op.kind.as_str() { - UPLOAD_FINALIZE_KIND => { - let payload: UploadFinalizePayload = serde_json::from_str(&op.payload_json) - .with_context(|| format!("Parse upload_finalize payload for {}", op.id))?; - referenced_upload_stage_dirs.insert(payload.stage_dir.clone()); - finalize_upload_payload_for_op(&conn, upload_dir, Some(&op.id), &payload)?; - } DELETE_FILES_KIND => { let payload: DeleteFilesPayload = serde_json::from_str(&op.payload_json) .with_context(|| format!("Parse delete_files payload for {}", op.id))?; @@ -1635,9 +1665,7 @@ fn apply_pending_fs_op( } other => anyhow::bail!("Unknown pending_fs_op kind {other:?} for {}", op.id), } - if op.kind != UPLOAD_FINALIZE_KIND { - crate::db::delete_pending_fs_op(&conn, &op.id)?; - } + crate::db::delete_pending_fs_op(&conn, &op.id)?; Ok(()) } diff --git a/src/server/server.rs b/src/server/server.rs index 7ba9194f..0aa47491 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -792,16 +792,16 @@ async fn run_server_lifecycle( loop { tokio::select! { _ = iv.tick() => { - let connection = bg.get(); - if let Ok(conn) = connection { - let purge_result = crate::db::purge_expired_sessions(&conn); - match purge_result { - Ok(n) if n > 0 => { - tracing::info!(target: "sessions", purged = n, "Expired sessions purged"); - } - Err(e) => tracing::error!("Session purge error: {e}"), - Ok(_) => {} - } + let purge_pool = bg.clone(); + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let conn = purge_pool.get()?; + crate::db::purge_expired_sessions(&conn) + }).await; + match result { + Ok(Ok(n)) if n > 0 => tracing::info!(target: "sessions", purged = n, "Expired sessions purged"), + Ok(Ok(_)) => {}, + Ok(Err(error)) => tracing::error!(error = %error, "Session purge error"), + Err(error) => tracing::error!(error = %error, "Session purge blocking task failed"), } } () = cancel_clone.cancelled() => { @@ -842,18 +842,22 @@ async fn run_server_lifecycle( ); continue; } - let connection = bg.get(); - if let Ok(conn) = connection { - match crate::db::run_wal_checkpoint(&conn) { - Ok((pages, moved, backfill)) => { - tracing::debug!("WAL checkpoint: {pages} pages total, {moved} moved, {backfill} backfilled"); - } - Err(e) => tracing::warn!("WAL checkpoint failed: {e}"), - } - // Fix #7: reuse `conn` instead of calling bg.get() again. - // A second acquire while the first is still alive deadlocks - // with a pool size of 1. - drop(conn.execute_batch("PRAGMA optimize;")); + let checkpoint_pool = bg.clone(); + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let conn = checkpoint_pool.get()?; + let (log_pages, checkpointed_pages, busy) = crate::db::run_wal_checkpoint(&conn)?; + tracing::debug!(target: "db", log_pages, checkpointed_pages, busy, + "WAL checkpoint completed"); + // Reuse the connection so a one-connection pool cannot + // deadlock on a nested checkout. Both operations can + // wait for SQLite and must stay off async workers. + conn.execute_batch("PRAGMA optimize;")?; + Ok(()) + }).await; + match result { + Ok(Ok(())) => {}, + Ok(Err(error)) => tracing::warn!(target: "db", error = %error, "WAL checkpoint or planner maintenance failed"), + Err(error) => tracing::warn!(target: "db", error = %error, "WAL maintenance blocking task failed"), } } () = cancel_clone.cancelled() => { diff --git a/src/server/server/assets.rs b/src/server/server/assets.rs index f69b2457..67e32aaa 100644 --- a/src/server/server/assets.rs +++ b/src/server/server/assets.rs @@ -92,8 +92,8 @@ mod tests { "background: rgba(0,0,0,0.42);", ".user-preferences-mobile-close {\n display: none;", "visibility: hidden;", - ".user-preferences-panel[open] .user-preferences-form {\n position: fixed;" - ,"bottom: 0;", + ".user-preferences-panel[open] .user-preferences-form {\n position: fixed;", + "bottom: 0;", "inset-block-end: 0;", "margin: 0 auto;", "transform: translate3d(0, 0, 0);", @@ -164,10 +164,10 @@ mod tests { "max-height: min(70vh, 26rem);", ".edit-modal,\n .compress-modal {", "align-items: flex-start;", - "padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom));" - ,".edit-modal-box,\n .compress-modal-box {", - "max-height: calc(100svh - 24px - env(safe-area-inset-top) - env(safe-area-inset-bottom));" - ,".edit-modal-box .post-form td:last-child {", + "padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom));", + ".edit-modal-box,\n .compress-modal-box {", + "max-height: calc(100svh - 24px - env(safe-area-inset-top) - env(safe-area-inset-bottom));", + ".edit-modal-box .post-form td:last-child {", ".edit-modal-box .edit-btn[data-action=\"close-edit-modal\"]", ] { assert!( diff --git a/src/server/server/observability.rs b/src/server/server/observability.rs index cd51a982..285e72a8 100644 --- a/src/server/server/observability.rs +++ b/src/server/server/observability.rs @@ -275,6 +275,18 @@ async fn metrics_response(state: AppState) -> Response { .await .unwrap_or((0, 0, false, 0, false, -1)); + let pool_state = state.db.state(); + let database_bytes = tokio::fs::metadata(&CONFIG.database_path) + .await + .map_or(-1, |metadata| { + i64::try_from(metadata.len()).unwrap_or(i64::MAX) + }); + let wal_bytes = tokio::fs::metadata(format!("{}-wal", CONFIG.database_path)) + .await + .map_or(-1, |metadata| { + i64::try_from(metadata.len()).unwrap_or(i64::MAX) + }); + let body = format!( concat!( "# TYPE rustchan_requests_total counter\n", @@ -311,6 +323,14 @@ async fn metrics_response(state: AppState) -> Response { "rustchan_media_reconcile_scan_incomplete_total {}\n", "# TYPE rustchan_database_schema_valid gauge\n", "rustchan_database_schema_valid{{version=\"{}\"}} {}\n", + "# TYPE rustchan_database_pool_connections gauge\n", + "rustchan_database_pool_connections {}\n", + "# TYPE rustchan_database_pool_idle gauge\n", + "rustchan_database_pool_idle {}\n", + "# TYPE rustchan_database_file_bytes gauge\n", + "rustchan_database_file_bytes {}\n", + "# TYPE rustchan_database_wal_file_bytes gauge\n", + "rustchan_database_wal_file_bytes {}\n", "# TYPE rustchan_full_backups_saved gauge\n", "rustchan_full_backups_saved {}\n", "# TYPE rustchan_latest_full_backup_verified gauge\n", @@ -352,6 +372,10 @@ async fn metrics_response(state: AppState) -> Response { media_reconcile.incomplete_scans_total, crate::db::baseline_schema_version(), u8::from(database_schema_valid), + pool_state.connections, + pool_state.idle_connections, + database_bytes, + wal_bytes, full_backup_count, u8::from(latest_full_backup_verified), latest_full_backup_age_seconds, @@ -501,6 +525,10 @@ mod tests { ); for metric in [ + "rustchan_database_pool_connections", + "rustchan_database_pool_idle", + "rustchan_database_file_bytes", + "rustchan_database_wal_file_bytes", "rustchan_requests_total", "rustchan_job_queue_pending", "rustchan_media_reconcile_files_scanned_total", diff --git a/tests/e2e/audit-nojs-parity.spec.ts b/tests/e2e/audit-nojs-parity.spec.ts index 322d7e6f..62e83a48 100644 --- a/tests/e2e/audit-nojs-parity.spec.ts +++ b/tests/e2e/audit-nojs-parity.spec.ts @@ -675,13 +675,24 @@ test.describe('no-JavaScript server-rendered parity', () => { await form.locator('textarea[name="body"]').fill(marker('nojs-upload-body')); await form.locator('input[type="file"]').first().setInputFiles(app.fixtures().tinyPng); await form.getByRole('button', { name: /post thread/i }).click(); - await waitForThreadRedirect(page, 'img'); + const threadId = await waitForThreadRedirect(page, 'img'); await expect(page.locator('.post.op .file-info')).toContainText('tiny.png'); const mediaHref = await page.locator('.post.op .file-info a').first().getAttribute('href'); expect(mediaHref).toMatch(/^\/boards\/img\//); const media = await page.request.get(`${app.baseURL}${mediaHref}`); expect(media.status()).toBe(200); - expect(media.headers()['content-type']).toMatch(/^image\/png/); + // Production may retain PNG or publish its smaller lossless WebP output. + // Check the persisted format and actual bytes, rather than the upload name. + const storedMime = sqliteQuery(app, `SELECT mime_type FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`); + expect(media.headers()['content-type']).toBe(storedMime); + const payload = await media.body(); + if (storedMime === 'image/webp') { + expect(payload.subarray(0, 4).toString('ascii')).toBe('RIFF'); + expect(payload.subarray(8, 12).toString('ascii')).toBe('WEBP'); + } else { + expect(storedMime).toBe('image/png'); + expect(payload.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + } expect(media.headers()['x-content-type-options']).toBe('nosniff'); await page.goto(`${app.baseURL}/img`); From af4f3da799388052af70db573ae7989915d5252e Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 13:59:34 -0700 Subject: [PATCH 08/11] Start CLI once a usable database connection is ready --- docs/sqlite-engineering.md | 26 ++++++++++++ src/db/pool.rs | 84 +++++++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docs/sqlite-engineering.md b/docs/sqlite-engineering.md index 93510c7b..3bd82987 100644 --- a/docs/sqlite-engineering.md +++ b/docs/sqlite-engineering.md @@ -438,3 +438,29 @@ local evidence. Operators should watch pool wait/hold warnings, debug lock/SQL labels, job queues and WAL growth under their actual workload. Existing deep health/repair and exclusive restore work can be expensive by design. Nothing in these measurements justifies a database migration today. + +## 14. CLI startup readiness follow-up + +The published working revision `39675da` had three Linux CLI integration failures +at database-pool construction in CI run `37048226995`. Both Rust test binaries +passed, but separate CLI processes reported a one-second pool timeout. Unchanged +local CLI tests and 60 fresh Linux-container startups did not reproduce that +host-specific scheduling failure; those container runs used the previously built +validation image, not this source revision. + +A deterministic regression exposed an unnecessarily strict startup requirement: +r2d2's checked builder waits for the whole configured pool by default, even when +one fully initialized connection is usable. Holding spare initializers behind a +gate made the former builder fail with the same timeout. Production startup now +keeps eager background filling and checks out one initialized connection before +installing and verifying the schema. Pool capacity, one-second request checkout, +WAL, FULL synchronization, foreign keys and busy timeout are unchanged. An +additional regression rejects a database path that cannot yield any usable +connection. No startup timeout was increased. + +With Rust 1.99, the repaired tree passed all-target/all-feature checking, strict +Clippy (all, pedantic, nursery and cargo), all five CLI integration tests, 2,671 +workspace test executions with four test threads, dependency policy and the +all-feature workspace release build. Four opt-in tests remain ignored in each +Rust test binary. The exact scheduling cause of the earlier hosted failure is +still unproven; exact-head hosted CI remains the final platform verification. diff --git a/src/db/pool.rs b/src/db/pool.rs index 3b49a23b..d22aa4de 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -21,6 +21,21 @@ pub(super) const CONNECTION_PRAGMAS: &str = " /// Maximum time callers wait for a pooled connection. const POOL_CONNECTION_TIMEOUT: Duration = Duration::from_secs(1); +/// Require one initialized connection before startup; fill spare capacity asynchronously. +fn build_pool(manager: SqliteConnectionManager, pool_size: u32) -> Result { + // r2d2's build() waits for max_size connections by default. That couples + // first-run startup to every spare initializer's scheduling even when a + // usable connection is ready. Keep eager refill and the one-second request + // deadline, but validate readiness through a real checkout instead. + let pool = Pool::builder() + .max_size(pool_size) + .event_handler(Box::new(super::diagnostics::PoolEvents::new())) + .connection_timeout(POOL_CONNECTION_TIMEOUT) + .build_unchecked(manager); + drop(pool.get().context("Failed to initialize database pool")?); + Ok(pool) +} + /// Initialise the `SQLite` connection pool and ensure the schema exists. /// /// # Errors @@ -37,12 +52,7 @@ pub fn init_pool() -> Result { .with_init(|conn| conn.execute_batch(CONNECTION_PRAGMAS)); let pool_size = CONFIG.db_pool_size; - let pool = Pool::builder() - .max_size(pool_size) - .event_handler(Box::new(super::diagnostics::PoolEvents::new())) - .connection_timeout(POOL_CONNECTION_TIMEOUT) - .build(manager) - .context("Failed to build database pool")?; + let pool = build_pool(manager, pool_size)?; let conn = pool.get().context("Failed to get DB connection")?; install_or_migrate_schema(&conn)?; @@ -120,6 +130,68 @@ pub fn has_no_admin(pool: &DbPool) -> bool { mod tests { use anyhow::Result; + /// Holds spare connection initializers until the test releases them. + #[derive(Debug, Default)] + struct InitializationGate { + /// Whether spare initializers can finish. + released: parking_lot::Mutex, + /// Wakes blocked initializers when the test has observed startup. + wake: parking_lot::Condvar, + } + + /// Releases blocked initializers even when the startup regression fails. + #[derive(Debug)] + struct ReleaseInitializers(std::sync::Arc); + + impl Drop for ReleaseInitializers { + fn drop(&mut self) { + *self.0.released.lock() = true; + self.0.wake.notify_all(); + } + } + + #[test] + fn startup_uses_a_ready_connection_while_spare_initializers_are_blocked() -> Result<()> { + let directory = tempfile::tempdir()?; + let gate = std::sync::Arc::new(InitializationGate::default()); + let release = ReleaseInitializers(std::sync::Arc::clone(&gate)); + let openings = std::sync::atomic::AtomicUsize::new(0); + let manager = + r2d2_sqlite::SqliteConnectionManager::file(directory.path().join("startup.db")) + .with_init(move |conn| { + if openings.fetch_add(1, std::sync::atomic::Ordering::Relaxed) != 0 { + let mut released = gate.released.lock(); + while !*released { + gate.wake.wait(&mut released); + } + } + conn.execute_batch(super::CONNECTION_PRAGMAS) + }); + let result = super::build_pool(manager, 4); + drop(release); + let pool = result?; + anyhow::ensure!(pool.max_size() == 4, "startup changed pool capacity"); + anyhow::ensure!( + pool.connection_timeout() == super::POOL_CONNECTION_TIMEOUT, + "startup changed request checkout timeout" + ); + let conn = pool.get()?; + super::install_or_migrate_schema(&conn)?; + crate::db::verify_database_schema(&conn)?; + Ok(()) + } + + #[test] + fn startup_rejects_a_pool_with_no_usable_connection() -> Result<()> { + let directory = tempfile::tempdir()?; + let manager = r2d2_sqlite::SqliteConnectionManager::file(directory.path()); + anyhow::ensure!( + super::build_pool(manager, 4).is_err(), + "startup accepted an unusable database" + ); + Ok(()) + } + #[test] fn every_pool_connection_enforces_wal_full_and_foreign_keys() -> Result<()> { let pool = super::init_test_pool()?; From 178f51f3cad24472886db2acf2802535566815a0 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 14:09:29 -0700 Subject: [PATCH 09/11] Remove unused console helper and consolidate request and test utilities --- CHANGELOG.md | 4 +++- CONTRIBUTING.md | 8 +++---- src/handlers/admin/backup.rs | 18 ++++++---------- src/handlers/admin/backup/http.rs | 7 ------ src/handlers/admin/mod.rs | 9 +------- src/handlers/admin/moderation.rs | 9 +------- src/handlers/admin/settings/maintenance.rs | 9 +------- src/handlers/board.rs | 9 +------- src/handlers/mod.rs | 23 +++++++++----------- src/handlers/thread.rs | 9 +------- src/logging.rs | 6 ------ src/media/mod.rs | 4 ++-- src/media/prune.rs | 1 - src/media/thumbnail.rs | 3 ++- src/server/server/routes.rs | 10 +-------- src/test_fixtures.rs | 25 ++++++++++++++++++++++ src/utils/files/storage.rs | 15 +------------ 17 files changed, 59 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26125054..6f08ae9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ upgrades, public posting and reading improvements, and verified native software ### Upgrade notes -- Back up the complete data directory before upgrading. Existing database structures are verified and stamped for the running 1.6.0 package; Rust 1.91 remains the minimum supported compiler. +- Back up the complete data directory before upgrading. Existing database structures are verified and stamped for the running 1.6.0 package; Rust 1.99 is the minimum supported compiler. - Native self-updates require one-time operator setup on Linux GNU x86_64 or ARM64: separate service identities, the supplied systemd/polkit configuration, a protected updater executable and independently provisioned trusted public key. The updater is operator-managed and is not replaced by application updates. See [Software Updates](docs/software-updates.md). - Containers, macOS, Windows and custom installations use their deployment tools to install releases. Their admin panel can check releases but does not offer native installation. Keep container data volumes when recreating an instance. - `/readyz` additionally reports the running package version. It does not expose release availability, updater transactions or retained backup metadata. @@ -33,6 +33,8 @@ upgrades, public posting and reading improvements, and verified native software ### Fixed +- Let CLI startup proceed once one fully initialized database connection is ready while spare connections fill in the background; retain the pool capacity, one-second checkout bound, and database validation. + - Completed native updates clear the availability notice; stale discovery records cannot offer installation of the running or an older version. Rollbacks retain notice of a newer release. - Banned identities can no longer submit reports or poll votes; those mutations share the existing posting ban gate and enforce CSRF before authorization results are disclosed. Long ban reasons wrap on narrow screens. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5901e43..b8e8fedf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ has already been coordinated under [SECURITY.md](SECURITY.md). ## Rust and feature policy -The workspace currently declares Rust `1.91` as its minimum supported Rust +The workspace currently declares Rust `1.99` as its minimum supported Rust version (MSRV). Keep `rust-version` and the `cargo-msrv` metadata aligned; do not raise the MSRV incidentally. @@ -79,13 +79,13 @@ cargo test --locked --workspace --all-features Use the Playwright harness under `tests/e2e/` for public UI, admin UI, media, backup and restore, moderation, Tor/proxy, and no-JavaScript changes. The maintained harness source is tracked: `playwright.config.ts`, `package.json`, -`package-lock.json`, `tests/e2e/**`, and `.github/workflows/e2e.yml`. Generated -evidence is not: keep reports, traces, screenshots, videos, storage state, +`package-lock.json`, and `tests/e2e/**`. Browser validation runs locally. +Generated evidence is not: keep reports, traces, screenshots, videos, storage state, runtime databases, preserved fixture roots, audit output, and other browser artifacts under the matching `.gitignore` rules and out of commits. See `tests/e2e/README.md` for the normal regression and deep audit commands. Run focused scenarios first and the available browser matrix for broad changes. -Some media tests require `ffmpeg`, `ffprobe`, and specific codecs; do not replace +Some media tests require `ffmpeg` and specific codecs; do not replace deterministic fixtures with private uploads from a live site. Permanent Rust unit and integration tests remain normal repository source. diff --git a/src/handlers/admin/backup.rs b/src/handlers/admin/backup.rs index 96db634b..02569d2e 100644 --- a/src/handlers/admin/backup.rs +++ b/src/handlers/admin/backup.rs @@ -10,6 +10,7 @@ use crate::{ config::CONFIG, db, error::{AppError, Result}, + handlers::is_xml_http_request, middleware::AppState, models::{BackupInfo, BoardAccessMode}, utils::crypto::{new_session_id, verify_password}, @@ -105,10 +106,10 @@ use downloads::{prune_stale_temp_board_downloads, write_temp_board_download_toke #[cfg(test)] use http::admin_xhr_error_response; use http::{ - is_xml_http_request, log_restore_upload_started, redirect_page_response, - restore_auth_preflight, restore_error_redirect_target, restore_failure_response, - restore_start_response, restore_success_redirect_target, restore_upload_parse_response, - sanitize_board_short_value, stream_restore_upload_to_tempfile, validate_backup_zip_filename, + log_restore_upload_started, redirect_page_response, restore_auth_preflight, + restore_error_redirect_target, restore_failure_response, restore_start_response, + restore_success_redirect_target, restore_upload_parse_response, sanitize_board_short_value, + stream_restore_upload_to_tempfile, validate_backup_zip_filename, validate_saved_backup_reference, validate_streamed_restore_upload, RestoreKind, }; pub(in crate::server) use listing::latest_verified_full_backup_modified_time; @@ -625,6 +626,7 @@ mod tests { }; use crate::error::AppError; use crate::models::BackupBoardSummary; + use crate::test_support::admin_signed_csrf; use anyhow::{bail, ensure, Context as _, Result as TestResult}; use axum::{ body::{to_bytes, Body}, @@ -948,14 +950,6 @@ mod tests { Ok(()) } - fn admin_signed_csrf() -> String { - crate::utils::crypto::make_scoped_csrf_form_token( - "csrf123", - &crate::config::CONFIG.cookie_secret, - "session123", - ) - } - fn admin_cookie_jar() -> CookieJar { CookieJar::new() .add(Cookie::new("csrf_token", "csrf123")) diff --git a/src/handlers/admin/backup/http.rs b/src/handlers/admin/backup/http.rs index a746ff56..381fdc67 100644 --- a/src/handlers/admin/backup/http.rs +++ b/src/handlers/admin/backup/http.rs @@ -41,13 +41,6 @@ pub(in crate::server) async fn backup_request_logging_middleware( response } -pub(super) fn is_xml_http_request(headers: &HeaderMap) -> bool { - headers - .get("x-requested-with") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.eq_ignore_ascii_case("XMLHttpRequest")) -} - pub(super) fn admin_xhr_error_response(error: &AppError) -> Response { let handled = match error { AppError::NotFound(message) => Some((StatusCode::NOT_FOUND, message.clone())), diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index a342b87d..2637288d 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -2469,6 +2469,7 @@ mod tests { }; use crate::error::AppError; use crate::middleware::SecureCookieContext; + use crate::test_support::admin_signed_csrf; use anyhow::{bail, ensure, Context as _}; use axum::{ body::to_bytes, @@ -3387,14 +3388,6 @@ mod tests { Ok(()) } - fn admin_signed_csrf() -> String { - crate::utils::crypto::make_scoped_csrf_form_token( - "csrf123", - &crate::config::CONFIG.cookie_secret, - "session123", - ) - } - #[tokio::test] async fn live_log_requires_admin_auth() -> anyhow::Result<()> { let state = crate::test_support::app_state(); diff --git a/src/handlers/admin/moderation.rs b/src/handlers/admin/moderation.rs index 9dba6542..c7f502b1 100644 --- a/src/handlers/admin/moderation.rs +++ b/src/handlers/admin/moderation.rs @@ -724,18 +724,11 @@ pub(in crate::server) async fn admin_ip_report( mod tests { use super::super::{admin_panel_redirect_anchor_open, SESSION_COOKIE}; use super::*; + use crate::test_support::admin_signed_csrf; use anyhow::{ensure, Context as _}; use axum::extract::State; use axum_extra::extract::cookie::{Cookie, CookieJar}; - fn admin_signed_csrf() -> String { - crate::utils::crypto::make_scoped_csrf_form_token( - "csrf123", - &crate::config::CONFIG.cookie_secret, - "session123", - ) - } - fn build_admin_jar() -> CookieJar { CookieJar::new() .add(Cookie::new(SESSION_COOKIE, "session123")) diff --git a/src/handlers/admin/settings/maintenance.rs b/src/handlers/admin/settings/maintenance.rs index ddb7e511..303fe678 100644 --- a/src/handlers/admin/settings/maintenance.rs +++ b/src/handlers/admin/settings/maintenance.rs @@ -702,6 +702,7 @@ mod tests { admin_db_repair, admin_db_repair_status, admin_vacuum, parse_ffmpeg_timeout_secs_input, parse_media_prune_size_input, update_media_settings, PRE_REPAIR_BACKUP_FAILURE, }; + use crate::test_support::admin_signed_csrf; use anyhow::{bail, ensure, Context as _}; use axum::{ body::{to_bytes, Body}, @@ -731,14 +732,6 @@ mod tests { Ok(()) } - fn admin_signed_csrf() -> String { - crate::utils::crypto::make_scoped_csrf_form_token( - "csrf123", - &crate::config::CONFIG.cookie_secret, - "session123", - ) - } - fn posts_ai_trigger_sql(state: &crate::middleware::AppState) -> anyhow::Result { let conn = state.db.get().context("get database connection")?; conn.query_row( diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 17fd02ec..3361e391 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -13,7 +13,7 @@ use crate::{ config::CONFIG, db::{self}, error::{AppError, Result}, - handlers::{parse_post_multipart, posting, render, PostFormData}, + handlers::{is_xml_http_request, parse_post_multipart, posting, render, PostFormData}, middleware::{validate_csrf, validate_signed_csrf, AppState, SecureCookieContext}, models::{Board, Pagination, SearchQuery, SEARCH_QUERY_MAX_CHARS}, templates, @@ -159,13 +159,6 @@ fn thread_unread_counts( .collect() } -fn is_xml_http_request(headers: &HeaderMap) -> bool { - headers - .get("x-requested-with") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.eq_ignore_ascii_case("XMLHttpRequest")) -} - #[derive(Serialize)] struct XhrErrorPayload<'a> { error: &'a str, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index ea967870..26d7cca8 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -31,6 +31,15 @@ use std::collections::HashSet; use std::time::Duration; use tokio::io::AsyncWriteExt as _; +/// Classify enhanced requests; authentication and CSRF checks remain independent. +#[must_use] +pub(crate) fn is_xml_http_request(headers: &axum::http::HeaderMap) -> bool { + headers + .get("x-requested-with") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("XMLHttpRequest")) +} + /// MIME sniff bytes used by this handler. const MIME_SNIFF_BYTES: usize = 512; /// Text multipart field max bytes used by this handler. @@ -1668,6 +1677,7 @@ mod tests { parse_post_multipart, process_audio_first_uploads, MultipartEnvelopeScanner, TempUpload, PUBLIC_MULTIPART_ENVELOPE_LIMIT_MARKER, PUBLIC_MULTIPART_ENVELOPE_MAX_BYTES, }; + use crate::test_support::valid_pdf; use anyhow::{bail, ensure, Context as _}; use axum::{ body::Body, @@ -1704,19 +1714,6 @@ mod tests { )) } - fn valid_pdf() -> &'static [u8] { - b"%PDF-1.4 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >> endobj -4 0 obj << /Length 0 >> stream - -endstream endobj -trailer << /Root 1 0 R >> -%%EOF -" - } - async fn multipart_from_bytes( boundary: &str, body: Vec, diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 7e2e71c9..1b4e4ab7 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -15,7 +15,7 @@ use crate::{ admin_scoped_csrf_token, check_csrf_jar, ensure_csrf_for_request, ensure_csrf_with_secure, BoardAccessContext, }, - parse_post_multipart, posting, render, PostFormData, + is_xml_http_request, parse_post_multipart, posting, render, PostFormData, }, middleware::AppState, utils::crypto::hash_ip, @@ -38,13 +38,6 @@ type ThreadViewLoadResult = ( Option<(i64, i64)>, ); -fn is_xml_http_request(headers: &HeaderMap) -> bool { - headers - .get("x-requested-with") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.eq_ignore_ascii_case("XMLHttpRequest")) -} - // GET /:board/thread/:id #[expect( clippy::too_many_lines, diff --git a/src/logging.rs b/src/logging.rs index 2fc32dc7..d65fd010 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1276,12 +1276,6 @@ pub fn init_logging(log_dir: &Path) { // correctly with the tracing terminal layer. Use these instead of // `println!`/`print!` in `console.rs` and `detect.rs`. -/// Print `msg` followed by a newline to stdout, under the console lock. -pub fn console_println(msg: &str) { - let _guard = CONSOLE_MUTEX.lock(); - drop(writeln!(io::stdout(), "{msg}")); -} - /// Write a raw pre-formatted block exactly as provided (no trailing newline added). /// /// Used for banners, install-hint blocks, and stats sections that are already diff --git a/src/media/mod.rs b/src/media/mod.rs index 9de3cdf9..322b9094 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -284,8 +284,8 @@ impl MediaProcessor { /// Writes a WebP file (or SVG placeholder) to `thumb_dir / {file_stem}.{ext}`. /// /// # Errors - /// Returns an error only if preview generation and placeholder writing fail - /// AND writing the placeholder also fails. + /// Returns an error if decoding or encoding fails without an available fallback, + /// or if writing the fallback placeholder fails. pub fn generate_thumbnail( self, input_path: &Path, diff --git a/src/media/prune.rs b/src/media/prune.rs index 28899bae..6b52451b 100644 --- a/src/media/prune.rs +++ b/src/media/prune.rs @@ -213,7 +213,6 @@ fn prune_candidates>( Ok(report) } -/// Load and validate all active original-media candidates. /// Database-only candidate columns, collected before filesystem validation. type CandidateRow = ( i64, diff --git a/src/media/thumbnail.rs b/src/media/thumbnail.rs index f1dee3ab..5e62313b 100644 --- a/src/media/thumbnail.rs +++ b/src/media/thumbnail.rs @@ -132,7 +132,8 @@ pub fn override_pdf_renderer_mode(mode: TestPdfRendererMode) -> PdfRendererTestG /// Failed video extraction writes a `.svg` sibling placeholder. /// /// # Errors -/// Returns an error if thumbnail generation and placeholder writing both fail. +/// Returns an error if decoding or encoding fails without an available fallback, +/// or if writing the fallback placeholder fails. pub fn generate_thumbnail( input_path: &Path, mime: &str, diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs index 6427a530..cac1130a 100644 --- a/src/server/server/routes.rs +++ b/src/server/server/routes.rs @@ -478,6 +478,7 @@ fn admin_backup_routes() -> Router { /// Route-table integration tests. mod tests { use super::{admin_routes, public_routes}; + use crate::test_support::admin_signed_csrf; use anyhow::Context as _; use axum::{ body::{to_bytes, Body}, @@ -522,15 +523,6 @@ mod tests { Ok(()) } - /// Create the scoped CSRF token paired with the known test session. - fn admin_signed_csrf() -> String { - crate::utils::crypto::make_scoped_csrf_form_token( - "csrf123", - &crate::config::CONFIG.cookie_secret, - "session123", - ) - } - /// Return the cookie header paired with the known test session. fn admin_cookie_header() -> &'static str { "csrf_token=csrf123; chan_admin_session=session123" diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs index 1dba6078..fa6274af 100644 --- a/src/test_fixtures.rs +++ b/src/test_fixtures.rs @@ -139,3 +139,28 @@ pub(crate) fn multipart_body( body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); (boundary, body) } + +/// Signs the shared administrator fixture's CSRF value for its session. +#[must_use] +pub(crate) fn admin_signed_csrf() -> String { + crate::utils::crypto::make_scoped_csrf_form_token( + "csrf123", + &crate::config::CONFIG.cookie_secret, + "session123", + ) +} + +/// Returns a minimal structurally valid PDF fixture. +#[must_use] +pub(crate) fn valid_pdf() -> &'static [u8] { + b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >> endobj +4 0 obj << /Length 0 >> stream + +endstream endobj +trailer << /Root 1 0 R >> +%%EOF +" +} diff --git a/src/utils/files/storage.rs b/src/utils/files/storage.rs index bb9cdd6a..ee6c326d 100644 --- a/src/utils/files/storage.rs +++ b/src/utils/files/storage.rs @@ -1196,6 +1196,7 @@ mod tests { validate_adts_aac_bytes, validate_untrusted_image_dimensions, SaveUploadOptions, AMBIGUOUS_WEBM_MIME, }; + use crate::test_support::valid_pdf; use anyhow::{Context as _, Result}; use std::path::Path; @@ -1284,20 +1285,6 @@ mod tests { }) } - /// Returns a minimal structurally valid PDF fixture. - fn valid_pdf() -> &'static [u8] { - b"%PDF-1.4 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >> endobj -4 0 obj << /Length 0 >> stream - -endstream endobj -trailer << /Root 1 0 R >> -%%EOF -" - } - #[test] fn final_processed_size_validation_removes_oversized_outputs() -> Result<()> { let tempdir = tempfile::tempdir()?; From 604d21dbf7fe430049529124d525c32cb0f150a9 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 2 Oct 2026 23:02:37 -0700 Subject: [PATCH 10/11] Harden Rust 1.99 lint policy and resolve violations --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 10 +- CONTRIBUTING.md | 4 +- Cargo.toml | 50 +- README.md | 14 +- src/banner.rs | 33 +- src/cache.rs | 8 +- src/captcha.rs | 8 +- src/config.rs | 53 +- src/config/admin.rs | 78 +- src/config/admin/certificates.rs | 29 +- src/config/admin/runtime.rs | 16 +- src/config/backup_storage.rs | 4 +- src/config/operator.rs | 5 +- src/db/accounts.rs | 10 +- src/db/admin.rs | 54 +- src/db/banners.rs | 24 +- src/db/boards.rs | 102 +- src/db/fs_ops.rs | 6 +- src/db/migrations.rs | 3 +- src/db/mod.rs | 3 +- src/db/pool.rs | 53 +- src/db/posts.rs | 154 +- src/db/schema.rs | 103 +- src/db/setup.rs | 10 +- src/db/themes.rs | 28 +- src/db/threads.rs | 109 +- src/db/user_thread_prefs.rs | 41 +- src/db/workload.rs | 144 +- src/detect.rs | 26 +- src/error.rs | 14 +- src/handlers/admin/accounts.rs | 6 +- src/handlers/admin/auth.rs | 69 +- src/handlers/admin/backup.rs | 169 ++- src/handlers/admin/backup/archive.rs | 105 +- src/handlers/admin/backup/create.rs | 141 +- src/handlers/admin/backup/downloads.rs | 10 +- src/handlers/admin/backup/http.rs | 40 +- src/handlers/admin/backup/listing.rs | 4 +- src/handlers/admin/backup/restore_board.rs | 64 +- src/handlers/admin/backup/restore_full.rs | 256 +++- src/handlers/admin/backup/safety.rs | 61 +- src/handlers/admin/backup/storage.rs | 159 ++- src/handlers/admin/content.rs | 45 +- src/handlers/admin/mod.rs | 235 +-- src/handlers/admin/moderation.rs | 59 +- src/handlers/admin/restart.rs | 22 +- src/handlers/admin/settings.rs | 2 +- src/handlers/admin/settings/appearance.rs | 6 +- .../admin/settings/backup_settings.rs | 11 +- src/handlers/admin/settings/banners.rs | 29 +- src/handlers/admin/settings/board.rs | 2 +- src/handlers/admin/settings/maintenance.rs | 206 +-- src/handlers/admin/settings/network.rs | 4 +- src/handlers/admin/settings/runtime.rs | 4 +- src/handlers/admin/settings/site.rs | 8 +- src/handlers/admin/settings/themes.rs | 32 +- src/handlers/admin/updates.rs | 15 +- src/handlers/banner.rs | 9 +- src/handlers/board.rs | 67 +- src/handlers/board/access_preferences.rs | 34 +- src/handlers/board/catalog.rs | 14 +- src/handlers/board/create_thread.rs | 13 +- src/handlers/board/media.rs | 172 ++- src/handlers/board/pages.rs | 24 +- src/handlers/board/reports.rs | 3 +- src/handlers/board/tests.rs | 226 +-- src/handlers/captcha.rs | 11 +- src/handlers/favicon.rs | 2 +- src/handlers/mod.rs | 84 +- src/handlers/posting.rs | 441 ++++-- src/handlers/posting/performance.rs | 46 +- src/handlers/render.rs | 10 +- src/handlers/setup.rs | 104 +- src/handlers/thread.rs | 193 +-- src/logging.rs | 61 +- src/main.rs | 2 +- src/media/audio.rs | 85 +- src/media/audio_mp4.rs | 122 +- src/media/audio_opus.rs | 73 +- src/media/convert.rs | 2 +- src/media/ffmpeg.rs | 25 +- src/media/heif.rs | 45 +- src/media/images.rs | 45 +- src/media/images/tests.rs | 25 +- src/media/pdf.rs | 27 +- src/media/probe.rs | 34 +- src/media/probe/speex.rs | 49 +- src/media/process.rs | 212 ++- src/media/prune.rs | 102 +- src/media/reconcile.rs | 145 +- src/media/thumbnail.rs | 15 +- src/middleware/ip.rs | 32 +- src/middleware/state.rs | 97 +- src/models.rs | 114 +- src/pending_fs.rs | 120 +- src/restart.rs | 60 +- src/server/cli.rs | 55 +- src/server/console/browse.rs | 25 +- src/server/console/content.rs | 2 +- src/server/console/dashboard.rs | 27 +- src/server/console/dashboard_tests.rs | 50 +- src/server/console/dialogs.rs | 7 +- src/server/console/forms.rs | 22 +- src/server/console/help.rs | 10 +- src/server/console/history.rs | 8 +- src/server/console/input.rs | 44 +- src/server/console/mod.rs | 9 +- src/server/console/operator_views.rs | 2 +- src/server/console/state.rs | 281 +++- src/server/console/state_tests.rs | 116 +- src/server/console/stats.rs | 13 +- src/server/console/telemetry.rs | 20 +- src/server/console/widgets.rs | 35 +- src/server/console/wizard.rs | 34 +- src/server/server.rs | 207 ++- src/server/server/headers.rs | 20 +- src/server/server/lifecycle.rs | 18 +- src/server/server/observability.rs | 9 +- src/server/server/router.rs | 17 +- src/server/server/routes.rs | 45 +- src/templates/admin.rs | 184 +-- src/templates/admin/accounts.rs | 13 +- src/templates/admin/appearance.rs | 55 +- src/templates/admin/application.rs | 4 +- src/templates/admin/backups.rs | 107 +- src/templates/admin/control_center.rs | 65 +- src/templates/admin/maintenance.rs | 35 +- src/templates/admin/management.rs | 4 +- src/templates/admin/moderation.rs | 81 +- src/templates/admin/network.rs | 4 +- src/templates/admin/search.rs | 30 +- src/templates/admin/site_health.rs | 51 +- src/templates/admin/updates.rs | 44 +- src/templates/board.rs | 333 +++-- src/templates/forms.rs | 14 +- src/templates/mod.rs | 98 +- src/templates/thread.rs | 456 +++--- src/theme_builder.rs | 7 +- src/tls/acme.rs | 5 +- src/tls/self_signed.rs | 31 +- src/updates/daemon.rs | 38 +- src/updates/mod.rs | 28 +- src/updates/release.rs | 78 +- src/updates/snapshot.rs | 28 +- src/updates/transaction.rs | 89 +- src/updates/transaction_tests.rs | 298 +++- src/utils/files/disk_space.rs | 6 +- src/utils/files/storage.rs | 123 +- src/utils/fs_security.rs | 12 +- src/utils/sanitize.rs | 30 +- src/utils/sanitize/formatting.rs | 6 +- src/utils/tripcode.rs | 14 +- src/workers/mod.rs | 556 +++++--- tests/cli_runtime.rs | 1259 +++++++++-------- tests/e2e/audit-harness-contract.spec.ts | 23 +- tests/e2e/audit-permissions.spec.ts | 13 +- tests/e2e/diagnostics.ts | 3 +- tests/e2e/non-video-media.spec.ts | 30 +- .../phase4-responsive-long-content.spec.ts | 2 +- tests/e2e/upload-regressions.spec.ts | 42 +- tests/update_ui_fixtures.rs | 78 +- 162 files changed, 7168 insertions(+), 4200 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02bcd6d3..3bf50e06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,16 @@ jobs: - name: Check formatting run: cargo fmt --all --check + - name: Check all targets and features + run: cargo check --locked --workspace --all-targets --all-features + - name: Run Clippy - run: cargo clippy --locked --workspace --all-targets --all-features + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings + + - name: Build documentation + run: cargo doc --locked --workspace --all-features --no-deps + env: + RUSTDOCFLAGS: -D warnings - name: Enforce locked dependency policy run: cargo deny --locked check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fba1813e..420f28df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,8 +71,16 @@ jobs: - name: Check formatting run: cargo fmt --all --check + - name: Check all targets and features + run: cargo check --locked --workspace --all-targets --all-features + - name: Run Clippy - run: cargo clippy --locked --workspace --all-targets --all-features + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings + + - name: Build documentation + run: cargo doc --locked --workspace --all-features --no-deps + env: + RUSTDOCFLAGS: -D warnings - name: Test native update packaging and signing run: python3 -m unittest discover -s tools -p "test_update_package.py" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8e8fedf..ff604de7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,8 +72,10 @@ Rust checks are: ```sh cargo fmt --all --check -cargo clippy --locked --workspace --all-targets --all-features +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings cargo test --locked --workspace --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --all-features --no-deps ``` Use the Playwright harness under `tests/e2e/` for public UI, admin UI, media, diff --git a/Cargo.toml b/Cargo.toml index b820f74c..1a0cbabc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -197,32 +197,37 @@ warnings = { level = "deny", priority = -3 } future_incompatible = { level = "deny", priority = -2 } rust_2018_idioms = { level = "deny", priority = -2 } rust_2024_compatibility = { level = "deny", priority = -2 } +nonstandard_style = { level = "deny", priority = -2 } +let_underscore = { level = "deny", priority = -2 } unused = { level = "deny", priority = -2 } unsafe_code = "forbid" +unsafe_op_in_unsafe_fn = "deny" +raw_borrows_via_references = "deny" missing_docs = "deny" missing_debug_implementations = "deny" non_ascii_idents = "deny" unreachable_pub = "deny" +unused_crate_dependencies = "deny" +unused_results = "deny" +macro_use_extern_crate = "deny" +redundant_imports = "deny" +redundant_lifetimes = "deny" +single_use_lifetimes = "deny" +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unit_bindings = "deny" +unused_import_braces = "deny" unused_lifetimes = "deny" unused_qualifications = "deny" let_underscore_drop = "deny" -# This rustc lint can report false positives for dependencies shared across -# library, binary, example, test, benchmark, and feature-specific targets. -# Keep it visible, but do not force fake imports or dependency distortions. -unused_crate_dependencies = "warn" - [workspace.lints.clippy] all = { level = "deny", priority = -3 } pedantic = { level = "deny", priority = -2 } +nursery = { level = "deny", priority = -2 } cargo = { level = "deny", priority = -2 } -# This can represent unavoidable transitive version splits, especially in -# a dependency graph as large as Arti's. Audit every report without making -# unavoidable upstream duplication a hard failure. -multiple_crate_versions = "warn" - # Panic-driven control flow. unwrap_used = "deny" expect_used = "deny" @@ -243,6 +248,9 @@ print_stderr = "deny" undocumented_unsafe_blocks = "deny" multiple_unsafe_ops_per_block = "deny" missing_safety_doc = "deny" +unnecessary_safety_comment = "deny" +unnecessary_safety_doc = "deny" +default_union_representation = "deny" # Suppressions must be narrow, intentional, and documented. allow_attributes = "deny" @@ -250,6 +258,7 @@ allow_attributes_without_reason = "deny" # Numeric conversions must be explicit and reviewed. as_conversions = "deny" +arithmetic_side_effects = "deny" cast_lossless = "deny" cast_possible_truncation = "deny" cast_possible_wrap = "deny" @@ -259,16 +268,26 @@ cast_sign_loss = "deny" char_lit_as_u8 = "deny" fn_to_numeric_cast_any = "deny" lossy_float_literal = "deny" +default_numeric_fallback = "deny" +suspicious_xor_used_as_pow = "deny" +precedence_bits = "deny" # Avoid unchecked indexing and UTF-8 string slicing. indexing_slicing = "deny" string_slice = "deny" +# Ignored values and error mappings must preserve their meaning. +let_underscore_must_use = "deny" +let_underscore_untyped = "deny" +map_err_ignore = "deny" + # Ownership and resource-management restrictions. clone_on_ref_ptr = "deny" mem_forget = "deny" rc_buffer = "deny" rc_mutex = "deny" +string_add = "deny" +str_to_string = "deny" # Documentation, testing, and maintainability. missing_docs_in_private_items = "deny" @@ -276,6 +295,17 @@ missing_assert_message = "deny" large_include_file = "deny" verbose_file_reads = "deny" wildcard_dependencies = "deny" +mixed_read_write_in_expression = "deny" +shadow_unrelated = "deny" +wildcard_enum_match_arm = "deny" +partial_pub_fields = "deny" +redundant_type_annotations = "deny" +rest_pattern_accessible_field = "deny" +unnecessary_rest_pattern = "deny" +doc_include_without_cfg = "deny" +doc_paragraphs_missing_punctuation = "deny" +tests_outside_test_module = "deny" +host_endian_bytes = "deny" # Reject detected in-band SBR instead of publishing AAC-LC-only HE-AAC audio. [patch.crates-io] diff --git a/README.md b/README.md index 4f3efeb2..02230db6 100644 --- a/README.md +++ b/README.md @@ -211,14 +211,24 @@ As a site owner, keep settings, secrets, databases, backups, TLS keys, and Tor i RustChan is written in Rust using Axum, Tokio, bundled SQLite, server-rendered templates, Rustls, and Arti. Docker is optional for development. -Run the Rust checks before submitting changes: +Use Rust 1.99.0 and run the Rust checks before submitting changes: ```bash cargo fmt --all --check -cargo clippy --locked --workspace --all-targets --all-features +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings cargo test --locked --workspace --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --all-features --no-deps ``` +The workspace lint tables in `Cargo.toml` enforce the Rust and Clippy policy for +every RustChan target. They deny the main Clippy groups (`all`, `pedantic`, +`nursery`, and `cargo`) and explicit checks for panic paths, unchecked arithmetic +and indexing, conversions, ignored results, documentation, and resource safety. +Unsafe Rust is forbidden. New workspace packages must inherit this policy with +`[lints] workspace = true`. The patched upstream AAC dependency under `vendor/` +is excluded from the workspace and retains its upstream lint configuration. + Browser tests run locally; their harness and npm files are tracked, but GitHub Actions does not run browser checks. Keep browser reports and temporary screenshots out of commits; the demonstration screenshots in `docs/screenshots/` are published documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidance. ## Documentation diff --git a/src/banner.rs b/src/banner.rs index 0e318ffd..ac66109a 100644 --- a/src/banner.rs +++ b/src/banner.rs @@ -250,7 +250,10 @@ pub fn validate_banner_restore_entry_name(name: &str) -> Result { .next() .and_then(|component| match component { Component::Normal(value) => value.to_str(), - _ => None, + Component::Prefix(_) + | Component::RootDir + | Component::CurDir + | Component::ParentDir => None, }) .ok_or_else(|| anyhow::anyhow!("Banner restore entry name is not valid UTF-8."))?; if scope != "global" && scope != "home" { @@ -260,7 +263,10 @@ pub fn validate_banner_restore_entry_name(name: &str) -> Result { .next() .and_then(|component| match component { Component::Normal(value) => value.to_str(), - _ => None, + Component::Prefix(_) + | Component::RootDir + | Component::CurDir + | Component::ParentDir => None, }) .ok_or_else(|| anyhow::anyhow!("Banner restore entry name is not valid UTF-8."))?; let extension = Path::new(file_name) @@ -540,11 +546,16 @@ pub fn choose_active_banner( ); } if settings.rotation_interval_minutes > 0 { - let bucket = chrono::Utc::now().timestamp() - / settings - .rotation_interval_minutes - .saturating_mul(60) - .max(60); + let interval_seconds = settings + .rotation_interval_minutes + .saturating_mul(60) + .max(60); + let Some(bucket) = chrono::Utc::now().timestamp().checked_div(interval_seconds) else { + // The positive interval rules out both division by zero and the + // signed MIN / -1 overflow; retain an explicit fail-closed guard. + tracing::error!(interval_seconds, "Invalid banner rotation interval"); + return (None, "invalid-rotation".to_owned(), false); + }; let len = i64::try_from(candidates.len()).unwrap_or(1); let index = usize::try_from(bucket.rem_euclid(len)).unwrap_or(0); let asset = candidates.get(index).cloned(); @@ -557,7 +568,7 @@ pub fn choose_active_banner( let nonce = uuid::Uuid::new_v4().as_u128(); let len = u128::try_from(candidates.len()).unwrap_or(1); - let index = usize::try_from(nonce % len).unwrap_or(0); + let index = usize::try_from(nonce.rem_euclid(len)).unwrap_or(0); let asset = candidates.get(index).cloned(); let fragment = asset.as_ref().map_or_else( || "none".to_owned(), @@ -897,7 +908,7 @@ fn count_gif_frames(bytes: &[u8]) -> usize { let mut frame_markers = 0usize; for window in bytes.windows(2) { if window == [0x21, 0xF9] { - frame_markers += 1; + frame_markers = frame_markers.saturating_add(1); } } frame_markers.max(1) @@ -910,10 +921,10 @@ fn is_animated_webp(bytes: &[u8]) -> bool { } let mut offset = 12usize; while offset.saturating_add(8) <= bytes.len() { - let Some(chunk_type) = bytes.get(offset..offset + 4) else { + let Some(chunk_type) = bytes.get(offset..offset.saturating_add(4)) else { return false; }; - let Some(size_bytes) = bytes.get(offset + 4..offset + 8) else { + let Some(size_bytes) = bytes.get(offset.saturating_add(4)..offset.saturating_add(8)) else { return false; }; let Ok(size_bytes) = <[u8; 4]>::try_from(size_bytes) else { diff --git a/src/cache.rs b/src/cache.rs index 934e3495..96248ce2 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -18,14 +18,14 @@ pub const CACHE_CONTROL_IMMUTABLE_MEDIA: &str = "public, max-age=31536000, immut /// Insert `Cache-Control` only when the response does not already define it. pub fn insert_cache_control_if_absent(headers: &mut HeaderMap, value: &'static str) { - headers + let _configured = headers .entry(header::CACHE_CONTROL) .or_insert(HeaderValue::from_static(value)); } /// Replace the response's `Cache-Control` header with `value`. pub fn set_cache_control(headers: &mut HeaderMap, value: &'static str) { - headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(value)); + let _previous_value = headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(value)); } /// Ensure a response's `Vary` header includes `Cookie`. @@ -34,7 +34,7 @@ pub fn insert_vary_cookie(headers: &mut HeaderMap) { .get(header::VARY) .and_then(|value| value.to_str().ok()) else { - headers.insert(header::VARY, HeaderValue::from_static("Cookie")); + let _previous_value = headers.insert(header::VARY, HeaderValue::from_static("Cookie")); return; }; @@ -47,6 +47,6 @@ pub fn insert_vary_cookie(headers: &mut HeaderMap) { let combined = format!("{existing}, Cookie"); if let Ok(value) = HeaderValue::from_str(&combined) { - headers.insert(header::VARY, value); + let _previous_value = headers.insert(header::VARY, value); } } diff --git a/src/captcha.rs b/src/captcha.rs index 4372f4ee..669f9c71 100644 --- a/src/captcha.rs +++ b/src/captcha.rs @@ -94,7 +94,7 @@ pub fn generate_captcha_image( prune_expired(chrono::Utc::now().timestamp()); let mut captcha = Captcha::new(); - captcha + let _configured = captcha .set_chars(CAPTCHA_CHARSET) .add_chars(CAPTCHA_ANSWER_LEN) .apply_filter(Noise::new(0.25)) @@ -163,7 +163,7 @@ pub fn verify_captcha( /// Hash and store one challenge when its generated answer is valid. fn store_challenge(board_short: &str, captcha_id: &str, answer: &str, now: i64) { if let Some(answer_hash) = answer_hash(board_short, captcha_id, answer) { - CAPTCHA_CHALLENGES.insert( + let _previous_value = CAPTCHA_CHALLENGES.insert( captcha_id.to_owned(), StoredCaptchaChallenge { board_short: board_short.to_owned(), @@ -232,7 +232,7 @@ pub mod testing { let Some(answer_hash) = super::answer_hash(board_short, captcha_id, answer) else { return; }; - super::CAPTCHA_CHALLENGES.insert( + let _previous_value = super::CAPTCHA_CHALLENGES.insert( captcha_id.to_owned(), super::StoredCaptchaChallenge { board_short: board_short.to_owned(), @@ -250,7 +250,7 @@ pub mod testing { #[cfg(test)] mod tests { - use super::{testing, *}; + use super::*; #[test] fn generated_captcha_is_png_and_stores_server_side_answer() { diff --git a/src/config.rs b/src/config.rs index 569a8f34..03552477 100644 --- a/src/config.rs +++ b/src/config.rs @@ -185,9 +185,9 @@ pub fn configure_port_override(port: Option) -> anyhow::Result<()> { return Ok(()); }; anyhow::ensure!(port != 0, "--port must not be zero"); - CLI_PORT_OVERRIDE - .set(port) - .map_err(|_| anyhow::anyhow!("launcher port was already configured")) + CLI_PORT_OVERRIDE.set(port).map_err(|rejected_port| { + anyhow::anyhow!("launcher port {rejected_port} was already configured") + }) } /// Configure an explicit runtime data directory before configuration is loaded. @@ -201,9 +201,12 @@ pub fn configure_data_dir(path: Option<&Path>) -> anyhow::Result<()> { return Ok(()); }; let resolved = resolve_data_dir_override(path)?; - DATA_DIR_OVERRIDE - .set(resolved) - .map_err(|_| anyhow::anyhow!("runtime data directory was already configured")) + DATA_DIR_OVERRIDE.set(resolved).map_err(|rejected_path| { + anyhow::anyhow!( + "runtime data directory {} was already configured", + rejected_path.display() + ) + }) } #[must_use] @@ -911,6 +914,10 @@ pub fn describe_timeout_secs(timeout_secs: u64) -> String { reason = "runtime configuration intentionally models independent operator-controlled feature switches" )] /// Fully resolved runtime configuration. +#[expect( + clippy::partial_pub_fields, + reason = "settings remain public, while startup provenance is maintained only inside the crate" +)] pub struct Config { /// Authentication, public paging and request-deadline policies. pub operator: operator::OperatorSettings, @@ -1295,7 +1302,7 @@ impl Config { // CHAN_BIND's port. Resolve that before validation and state display. let bind_addr = cli_port.map_or_else( || bind_addr.clone(), - |port| format_bind_addr(bind_host_for_family(&bind_addr), port), + |override_port| format_bind_addr(bind_host_for_family(&bind_addr), override_port), ); let tor_only = environment.boolean("CHAN_TOR_ONLY", s.tor_only.unwrap_or(false)); let enable_tor_support = @@ -1628,21 +1635,23 @@ impl Config { } } for cidr in &self.trusted_proxy_cidrs { - cidr.parse::().map_err(|error| { - anyhow::anyhow!( + cidr.parse::() + .map_err(|error| { + anyhow::anyhow!( "CONFIG ERROR: trusted_proxy_cidrs entry '{cidr}' is not valid CIDR: {error}" ) - })?; + }) + .map(|_validated_value| ())?; } for host in &self.public_hosts { normalize_public_host(host).ok_or_else(|| { anyhow::anyhow!( "CONFIG ERROR: public_hosts entry '{host}' must be a bare hostname or IP literal." ) - })?; + }).map(|_operation_summary| ())?; } if let Some(path) = &self.backup_directory { - prepare_backup_directory(path, self)?; + prepare_backup_directory(path, self).map(|_validated_path| ())?; } // Verify the upload directory is writable. let upload_path = Path::new(&self.upload_dir); @@ -1690,7 +1699,7 @@ impl Config { ACME validation requires public HTTPS reachability, but tor_only binds RustChan to loopback." ); } - validate_ffmpeg_timeout_secs(self.ffmpeg_timeout_secs)?; + validate_ffmpeg_timeout_secs(self.ffmpeg_timeout_secs).map(|_operation_summary| ())?; Ok(()) } @@ -1740,7 +1749,7 @@ fn rewrite_settings_file_lines( let trimmed = line.trim_start(); for (key, value) in updates { if trimmed.starts_with(key) && line.contains('=') { - seen_keys.insert(*key); + let _inserted = seen_keys.insert(*key); return format!("{key} = {value}"); } } @@ -1777,7 +1786,7 @@ fn rewrite_settings_file_lines( if next_line.is_some_and(|line| !line.trim().is_empty()) { insertion_block.push(String::new()); } - updated_lines.splice(insert_idx..insert_idx, insertion_block); + drop(updated_lines.splice(insert_idx..insert_idx, insertion_block)); } let mut out = updated_lines.join("\n"); @@ -1798,20 +1807,20 @@ fn update_settings_file_entries_result( /// Persist a complete file update and compensate it if a prepared DB commit fails. fn update_settings_file_entries_with_commit( updates: &[(&str, String)], - insert_missing_before: Option<&str>, + _insert_missing_before: Option<&str>, commit: impl FnOnce() -> anyhow::Result<()>, ) -> anyhow::Result<()> { let _guard = admin::SETTINGS_WRITE_LOCK.lock(); - let _ = insert_missing_before; // Legacy anchors remain API-compatible; root insertion is structural. + // Legacy anchors remain API-compatible; root insertion is structural. let parsed = updates .iter() .map(|(key, value)| { let document: toml::Value = toml::from_str(&format!("value = {value}"))?; - let value = document + let parsed_value = document .get("value") .cloned() .context("missing settings value")?; - Ok(((*key).to_owned(), Some(value))) + Ok(((*key).to_owned(), Some(parsed_value))) }) .collect::>>()?; admin::save_root_and_commit_at(&settings_file_path(), &parsed, commit) @@ -2084,7 +2093,8 @@ pub fn check_cookie_secret_rotation(conn: &rusqlite::Connection) -> anyhow::Resu if h == ¤t_hash { return Ok(()); // Secret unchanged — nothing to do. } - tx.execute("DELETE FROM admin_sessions", [])?; + tx.execute("DELETE FROM admin_sessions", []) + .map(|_affected_rows| ())?; tracing::warn!( "SECURITY WARNING: cookie_secret has changed since the last run. \ All IP-based bans are now invalid because all IP hashes have changed. \ @@ -2098,7 +2108,8 @@ pub fn check_cookie_secret_rotation(conn: &rusqlite::Connection) -> anyhow::Resu "INSERT INTO site_settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", rusqlite::params![KEY, current_hash], - )?; + ) + .map(|_affected_rows| ())?; tx.commit()?; Ok(()) } diff --git a/src/config/admin.rs b/src/config/admin.rs index 042670d2..047704c3 100644 --- a/src/config/admin.rs +++ b/src/config/admin.rs @@ -49,6 +49,10 @@ pub enum ApplicationMode { /// Definition shared by rendering, input validation and coverage checks. #[derive(Debug)] +#[expect( + clippy::partial_pub_fields, + reason = "renderers inspect metadata, while the config-only getter remains encapsulated" +)] pub struct SettingDefinition { /// Whether this control uses live state or immutable process initialization. pub application: ApplicationMode, @@ -125,8 +129,8 @@ fn snapshot_from( active: &Config, definitions: &'static [SettingDefinition], ) -> anyhow::Result> { - let raw: toml::Value = toml::from_str(content) - .map_err(|_| anyhow::anyhow!("settings.toml is invalid; repair it before saving"))?; + let raw: toml::Value = + toml::from_str(content).context("settings.toml is invalid; repair it before saving")?; let saved = resolve_file(content, &Environment::Values(&BTreeMap::new()))?; let next = resolve_file(content, &Environment::Process)?; Ok(definitions @@ -314,7 +318,11 @@ pub(super) fn environment_override_is_valid( InputKind::OptionalText if definition.key == "log_filter" => { tracing_subscriber::EnvFilter::try_new(value).is_ok() } - _ => true, + InputKind::Boolean + | InputKind::List + | InputKind::Address + | InputKind::Text + | InputKind::OptionalText => true, } } @@ -335,10 +343,10 @@ fn setting_source(definition: &SettingDefinition, active: &Config) -> String { /// Resolve file configuration without filesystem validation or secret exposure. pub(crate) fn resolve_file(content: &str, environment: &Environment<'_>) -> anyhow::Result { let mut settings: SettingsFile = super::parse_settings_file_str(content) - .map_err(|_| anyhow::anyhow!("settings.toml is invalid; repair it before saving"))?; + .context("settings.toml is invalid; repair it before saving")?; // A preview does not use secrets. Avoid generating a fallback secret for a // manually authored file that omitted it; startup remains authoritative. - settings.cookie_secret.get_or_insert_with(|| "0".repeat(64)); + let _initialized_secret = settings.cookie_secret.get_or_insert_with(|| "0".repeat(64)); Ok(Config::from_settings(settings, environment)) } @@ -398,7 +406,8 @@ fn parse_settings_form( if field.key == "trusted_proxy_cidrs" { entry .parse::() - .with_context(|| format!("invalid trusted proxy CIDR: {entry}"))?; + .with_context(|| format!("invalid trusted proxy CIDR: {entry}")) + .map(|_validated_value| ())?; } else { ensure!(valid_public_host(entry), "invalid public hostname: {entry}"); } @@ -425,7 +434,8 @@ fn parse_settings_form( } InputKind::Policy => { text.parse::() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::msg) + .map(|_validated_value| ())?; Some(toml::Value::String(text.to_owned())) } }; @@ -499,7 +509,8 @@ fn validate_network(config: &Config) -> anyhow::Result<()> { ); for cidr in &config.trusted_proxy_cidrs { cidr.parse::() - .context("invalid trusted proxy CIDR")?; + .context("invalid trusted proxy CIDR") + .map(|_validated_value| ())?; } for host in &config.public_hosts { ensure!(valid_public_host(host), "invalid public hostname: {host}"); @@ -597,10 +608,9 @@ pub(super) fn rewrite_root_settings( if updates.keys().any(|key| key.contains('.')) { return certificates::rewrite_tls(content, updates); } - let spans: BTreeMap> = toml::from_str(content) - .map_err(|_| anyhow::anyhow!("settings.toml is invalid; no changes saved"))?; - let mut expected: toml::Value = - toml::from_str(content).map_err(|_| anyhow::anyhow!("invalid settings.toml"))?; + let spans: BTreeMap> = + toml::from_str(content).context("settings.toml is invalid; no changes saved")?; + let mut expected: toml::Value = toml::from_str(content).context("invalid settings.toml")?; let table = expected .as_table_mut() .context("settings root must be a table")?; @@ -608,9 +618,9 @@ pub(super) fn rewrite_root_settings( let mut missing = String::new(); for (key, value) in updates { if let Some(value) = value { - table.insert(key.clone(), value.clone()); + let _previous_value = table.insert(key.clone(), value.clone()); } else { - table.remove(key); + let _previous_value = table.remove(key); } if let Some(span) = spans.get(key) { let range = span.span(); @@ -620,7 +630,9 @@ pub(super) fn rewrite_root_settings( } } else { let prefix = content.get(..range.start).context("invalid TOML span")?; - let start = prefix.rfind('\n').map_or(0, |offset| offset + 1); + let start = prefix + .rfind('\n') + .map_or(0, |offset| offset.saturating_add(1)); edits.push((start..range.end, String::new())); } } else if let Some(value) = value { @@ -628,12 +640,13 @@ pub(super) fn rewrite_root_settings( } } if !missing.is_empty() { - let mut offset = 0; + // Split lines partition content, so these offsets remain bounded by its byte length. + let mut offset = 0_usize; let first_table = content .split_inclusive('\n') .find_map(|line| { let start = offset; - offset += line.len(); + offset = offset.saturating_add(line.len()); let within_value = spans .values() .any(|v| !v.get_ref().is_table() && v.span().contains(&start)); @@ -659,7 +672,7 @@ pub(super) fn rewrite_root_settings( output.replace_range(range, &replacement); } let actual: toml::Value = toml::from_str(&output) - .map_err(|_| anyhow::anyhow!("settings rewrite failed verification; no changes saved"))?; + .context("settings rewrite failed verification; no changes saved")?; ensure!( actual == expected, "settings rewrite changed unrelated values; no changes saved" @@ -705,10 +718,12 @@ pub(crate) fn atomic_replace(path: &Path, content: &str) -> anyhow::Result<()> { Err(error) => return Err(error.into()), } } - temporary - .persist(path) - .map_err(|e| e.error) - .context("atomically replace settings.toml")?; + drop( + temporary + .persist(path) + .map_err(|e| e.error) + .context("atomically replace settings.toml")?, + ); #[cfg(unix)] std::fs::File::open(parent)?.sync_all()?; Ok(()) @@ -779,7 +794,8 @@ mod tests { "CREATE TABLE sample(value TEXT); INSERT INTO sample VALUES ('original');", )?; let tx = database.transaction()?; - tx.execute("UPDATE sample SET value='pending'", [])?; + tx.execute("UPDATE sample SET value='pending'", []) + .map(|_affected_rows| ())?; let changes = BTreeMap::from([("port".to_owned(), Some(toml::Value::Integer(9000)))]); let result = super::save_root_and_commit_at(&path, &changes, move || { drop(tx); @@ -838,20 +854,20 @@ mod tests { ("rate_limit_policy", "none"), ] { let mut invalid = form.clone(); - invalid.insert(key.to_owned(), input.to_owned()); + drop(invalid.insert(key.to_owned(), input.to_owned())); ensure!( parse_network_form(&invalid).is_err(), "must reject {key}={input}" ); } let mut invalid = form.clone(); - invalid.insert("cookie_secret".to_owned(), "replacement".to_owned()); + drop(invalid.insert("cookie_secret".to_owned(), "replacement".to_owned())); ensure!( parse_network_form(&invalid).is_err(), "network form must not accept secret rotation" ); let mut missing = form; - missing.remove("trusted_proxy_cidrs"); + drop(missing.remove("trusted_proxy_cidrs")); ensure!( parse_network_form(&missing).is_err(), "partial form must not save" @@ -992,7 +1008,7 @@ mod tests { let before = "# preserved\nport = 8080\nrate_limit_gets = 60\nenable_tor_support = false\n[tls]\nenabled = true\nport = 8443\n"; std::fs::write(&path, before)?; let mut form = valid_form()?; - form.insert("port".to_owned(), "8443".to_owned()); + drop(form.insert("port".to_owned(), "8443".to_owned())); let invalid = parse_network_form(&form)?; let empty = BTreeMap::new(); ensure!( @@ -1003,8 +1019,8 @@ mod tests { std::fs::read_to_string(&path)? == before, "failure must leave original bytes intact" ); - form.insert("port".to_owned(), "9000".to_owned()); - form.insert("rate_limit_gets".to_owned(), "120".to_owned()); + drop(form.insert("port".to_owned(), "9000".to_owned())); + drop(form.insert("rate_limit_gets".to_owned(), "120".to_owned())); let valid = parse_network_form(&form)?; let active = resolve_file(before, &Environment::Values(&empty))?; save_network_at(&path, &valid, &Environment::Values(&empty))?; @@ -1168,11 +1184,11 @@ pub(crate) fn settings_lease(path: &Path) -> anyhow::Result { } } let mut options = std::fs::File::options(); - options.read(true).write(true).create(true).truncate(false); + let _access_options = options.read(true).write(true).create(true).truncate(false); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; - options + let _platform_options = options .mode(0o600) .custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?); } diff --git a/src/config/admin/certificates.rs b/src/config/admin/certificates.rs index a2cc50a6..4daf0a6b 100644 --- a/src/config/admin/certificates.rs +++ b/src/config/admin/certificates.rs @@ -161,7 +161,8 @@ fn validate_pair(cert_path: &Path, key_path: &Path) -> anyhow::Result<()> { .context("TLS protocol configuration")? .with_no_client_auth() .with_single_cert(certificates, key) - .context("certificate and key do not form a usable pair")?; + .context("certificate and key do not form a usable pair") + .map(|_validated_value| ())?; Ok(()) } @@ -175,7 +176,9 @@ fn read_material(path: &Path) -> anyhow::Result> { "certificate/key must be a regular file no larger than 1 MiB" ); let mut bytes = Vec::new(); - file.take(1_048_577).read_to_end(&mut bytes)?; + file.take(1_048_577) + .read_to_end(&mut bytes) + .map(|_bytes_read| ())?; ensure!( bytes.len() <= 1_048_576, "certificate/key grew beyond 1 MiB" @@ -198,7 +201,7 @@ pub(super) fn rewrite_tls( matches!(table, "tls" | "tls.acme" | "tls.manual_cert"), "unknown TLS table" ); - groups + let _previous_value = groups .entry(table) .or_default() .insert(key.to_owned(), value.clone()); @@ -212,11 +215,13 @@ pub(super) fn rewrite_tls( /// Locate real headers by parsing them; ignore header-like lines in scalar values. fn table_range(content: &str, table: &str) -> anyhow::Result>> { let spans: BTreeMap> = toml::from_str(content)?; - let mut offset = 0; + let mut offset = 0_usize; let mut found = None; for line in content.split_inclusive('\n') { let start = offset; - offset += line.len(); + offset = offset + .checked_add(line.len()) + .context("TLS table offset overflow")?; if !line.trim_start().starts_with('[') || spans .values() @@ -262,14 +267,14 @@ fn rewrite_table( } for (key, value) in updates { if let Some(value) = value { - target.insert(key.clone(), value.clone()); + let _previous_value = target.insert(key.clone(), value.clone()); } else { - target.remove(key); + let _previous_value = target.remove(key); } } let remove_manual = table == "tls.manual_cert" && target.is_empty(); if remove_manual { - expected + let _previous_value = expected .get_mut("tls") .and_then(toml::Value::as_table_mut) .context("missing TLS")? @@ -278,7 +283,9 @@ fn rewrite_table( let mut output = content.to_owned(); if let Some(range) = table_range(content, table)? { let section = content.get(range.clone()).context("invalid TLS range")?; - let header_end = section.find('\n').map_or(section.len(), |p| p + 1); + let header_end = section + .find('\n') + .map_or(section.len(), |p| p.saturating_add(1)); let body = section.get(header_end..).context("invalid TLS header")?; // Parsing the isolated body ensures no nested/inline table gets silently overwritten. let parsed: BTreeMap> = @@ -363,7 +370,7 @@ mod tests { fn tls_roundtrip_preserves_comments_and_clears_optional_sources() -> anyhow::Result<()> { let before = "# keep root\nport = 3000\n[tls] # listener\nenabled = false # keep inline\n[tls.acme]\nstaging = true # test CA\ndomains = [\n 'example.test', # keep note\n]\n[unrelated]\nvalue = 42\n"; let mut form = form(before)?; - form.insert("tls.port".into(), "9443".into()); + let _previous_value = form.insert("tls.port".into(), "9443".into()); let updates = super::super::parse_settings_form(SETTINGS, &form)?; let after = rewrite_tls(before, &updates)?; for comment in [ @@ -401,7 +408,7 @@ mod tests { ("tls.acme.cache_dir", "../outside"), ] { let mut form = form(before)?; - form.insert(key.into(), value.into()); + let _previous_value = form.insert(key.into(), value.into()); let updates = super::super::parse_settings_form(SETTINGS, &form)?; ensure!( super::super::save_root_at( diff --git a/src/config/admin/runtime.rs b/src/config/admin/runtime.rs index 028e0f8e..c7676aac 100644 --- a/src/config/admin/runtime.rs +++ b/src/config/admin/runtime.rs @@ -81,7 +81,14 @@ impl RuntimeSection { pub const fn route_key(self) -> &'static str { match self { Self::Maintenance => "maintenance", - _ => self.key(), + Self::Tor + | Self::Media + | Self::System + | Self::Https + | Self::Access + | Self::Display + | Self::Logging + | Self::Timeouts => self.key(), } } } @@ -163,7 +170,8 @@ pub(super) fn validate_section(section: RuntimeSection, config: &Config) -> anyh config .tor_service_nickname .parse::() - .context("invalid Tor service nickname")?; + .context("invalid Tor service nickname") + .map(|_validated_value| ())?; } if section == RuntimeSection::Media { // Existing operator overrides may name an unavailable optional PATH tool. @@ -347,7 +355,7 @@ mod tests { RuntimeSection::Logging => ("log_filter", "warn"), RuntimeSection::Timeouts => ("read_timeout_secs", "45"), }; - form.insert(key.to_owned(), replacement.to_owned()); + let _previous_value = form.insert(key.to_owned(), replacement.to_owned()); let updates = parse_settings_form(section.definitions(), &form)?; save_root_at( &path, @@ -410,7 +418,7 @@ mod tests { (RuntimeSection::System, "db_pool_size", "0"), ] { let mut form = form_for(section)?; - form.insert(key.to_owned(), invalid.to_owned()); + let _previous_value = form.insert(key.to_owned(), invalid.to_owned()); let result = parse_settings_form(section.definitions(), &form).and_then(|updates| { save_root_at( &path, diff --git a/src/config/backup_storage.rs b/src/config/backup_storage.rs index 229426d2..946a71ad 100644 --- a/src/config/backup_storage.rs +++ b/src/config/backup_storage.rs @@ -193,10 +193,10 @@ mod tests { let existing = temp.path().join("existing"); std::fs::create_dir(&existing)?; std::fs::write(existing.join("operator-file"), "keep")?; - let result = prepare_and_persist(&existing, &config, |_| { + let preserve_existing_result = prepare_and_persist(&existing, &config, |_| { anyhow::bail!("injected persistence failure") }); - ensure!(result.is_err()); + ensure!(preserve_existing_result.is_err()); ensure!(std::fs::read_to_string(existing.join("operator-file"))? == "keep"); ensure!( std::fs::read_dir(&existing)?.count() == 1, diff --git a/src/config/operator.rs b/src/config/operator.rs index 1edf67fc..d6db178e 100644 --- a/src/config/operator.rs +++ b/src/config/operator.rs @@ -2,7 +2,7 @@ use super::Environment; use crate::config::admin::{InputKind, SettingDefinition}; -use anyhow::ensure; +use anyhow::{ensure, Context as _}; /// Additional policies consumed by authentication, paging and request boundaries. #[derive(Debug, Clone)] @@ -157,7 +157,8 @@ impl OperatorSettings { if let Some(filter) = &self.log_filter { ensure!(filter.len() <= 1024, "log filter is too long"); tracing_subscriber::EnvFilter::try_new(filter) - .map_err(|_| anyhow::anyhow!("invalid tracing filter"))?; + .context("invalid tracing filter") + .map(|_validated_value| ())?; } Ok(()) } diff --git a/src/db/accounts.rs b/src/db/accounts.rs index 22acb15d..5d12892f 100644 --- a/src/db/accounts.rs +++ b/src/db/accounts.rs @@ -58,7 +58,7 @@ pub fn manage_account( super::get_admin_by_username(&tx, username)?.is_none(), "username already exists" ); - super::create_admin(&tx, username, &hash)?; + super::create_admin(&tx, username, &hash).map(|_created_id| ())?; } AccountAction::Password => { let target = @@ -67,7 +67,8 @@ pub fn manage_account( tx.execute( "DELETE FROM admin_sessions WHERE admin_id = ?1", [target.id], - )?; + ) + .map(|_affected_rows| ())?; } } tx.commit()?; @@ -123,9 +124,10 @@ mod tests { super::super::get_session(&conn, "actor-session")?.is_some(), "other sessions must survive" ); - let target = super::super::get_admin_by_username(&conn, "target")?.context("target")?; + let updated_target = + super::super::get_admin_by_username(&conn, "target")?.context("target")?; ensure!( - crate::utils::crypto::verify_password("Replacement123", &target.password_hash)?, + crate::utils::crypto::verify_password("Replacement123", &updated_target.password_hash)?, "stored password must match" ); ensure!( diff --git a/src/db/admin.rs b/src/db/admin.rs index 64873cee..603b3085 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -191,7 +191,8 @@ pub fn create_session( "INSERT INTO admin_sessions (id, admin_id, expires_at) VALUES (?1, ?2, ?3)", params![session_id, admin_id, expires_at], ) - .context("Failed to create admin session")?; + .context("Failed to create admin session") + .map(|_affected_rows| ())?; Ok(()) } @@ -221,7 +222,8 @@ pub fn delete_session(conn: &rusqlite::Connection, session_id: &str) -> Result<( conn.execute( "DELETE FROM admin_sessions WHERE id = ?1", params![session_id], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -361,23 +363,22 @@ pub fn add_word_filter( /// # Errors /// Returns an error if the database operation fails. pub fn remove_word_filter(conn: &rusqlite::Connection, id: i64) -> Result<()> { - conn.execute("DELETE FROM word_filters WHERE id = ?1", params![id])?; + conn.execute("DELETE FROM word_filters WHERE id = ?1", params![id]) + .map(|_affected_rows| ())?; Ok(()) } // Reports /// Return whether an `SQLite` error represents the open-report uniqueness guard. fn is_open_report_unique_violation(error: &rusqlite::Error) -> bool { - match error { - rusqlite::Error::SqliteFailure(inner, message) => { - inner.code == rusqlite::ErrorCode::ConstraintViolation - && message.as_deref().is_some_and(|text| { - text.contains("idx_reports_open_unique") - || (text.contains("reports.post_id") - && text.contains("reports.reporter_hash")) - }) - } - _ => false, + if let rusqlite::Error::SqliteFailure(inner, message) = error { + inner.code == rusqlite::ErrorCode::ConstraintViolation + && message.as_deref().is_some_and(|text| { + text.contains("idx_reports_open_unique") + || (text.contains("reports.post_id") && text.contains("reports.reporter_hash")) + }) + } else { + false } } @@ -505,7 +506,8 @@ pub fn log_mod_action( board_short, detail ], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -662,7 +664,8 @@ pub fn accept_ban_appeal(conn: &rusqlite::Connection, appeal_id: i64) -> Result< .context("Failed to accept ban appeal")? .with_context(|| format!("Ban appeal id {appeal_id} not found or already handled"))?; conn.execute("DELETE FROM bans WHERE ip_hash=?1", params![ip_hash]) - .context("Failed to lift ban during appeal acceptance")?; + .context("Failed to lift ban during appeal acceptance") + .map(|_affected_rows| ())?; Ok(ip_hash) })(); @@ -755,7 +758,7 @@ pub fn get_posts_by_ip_hash( /// The raw PRAGMA `wal_checkpoint` pragma returns three columns in this order: /// col 0 — busy: 1 if a checkpoint could not complete due to an active reader/writer /// col 1 — log: total pages in the WAL file -/// col 2 — checkpointed: pages actually written back to the database +/// col 2 — checkpointed: pages actually written back to the database. /// /// This function returns `(log_pages, checkpointed_pages, busy)` — intentionally /// reordered so the two informational values come first and the error flag last. @@ -1102,7 +1105,7 @@ mod tests { fn ban_appeal_submission_is_deduplicated_within_window() -> Result<()> { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - crate::db::add_ban(&conn, "hash1", "reason", None)?; + crate::db::add_ban(&conn, "hash1", "reason", None).map(|_created_id| ())?; let first = file_ban_appeal(&conn, "hash1", "please unban")?; let second = file_ban_appeal(&conn, "hash1", "second try")?; @@ -1146,8 +1149,8 @@ mod tests { fn appeal_resolution_uses_the_stored_address_and_is_single_transition() -> Result<()> { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - crate::db::add_ban(&conn, "appealed-hash", "appealed", None)?; - crate::db::add_ban(&conn, "other-hash", "other", None)?; + crate::db::add_ban(&conn, "appealed-hash", "appealed", None).map(|_created_id| ())?; + crate::db::add_ban(&conn, "other-hash", "other", None).map(|_created_id| ())?; assert_eq!( file_ban_appeal(&conn, "appealed-hash", "please unban")?, BanAppealSubmission::Filed @@ -1392,7 +1395,7 @@ mod tests { let conn = pool.get()?; let ip_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - create_board(&conn, "test", "Test", "", false)?; + create_board(&conn, "test", "Test", "", false).map(|_created_id| ())?; let board = get_board_by_short(&conn, "test")?.context("test board should exist")?; let post = NewPost { thread_id: 0, @@ -1427,19 +1430,22 @@ mod tests { )?; let posts = get_posts_by_ip_hash(&conn, ip_hash, 25, 0)?; - let (post, board_short) = posts + let (history_post, board_short) = posts .first() .context("IP history should contain the post")?; assert_eq!(posts.len(), 1, "exactly one post should match the IP hash"); - assert_eq!(post.id, post_id, "the created post should be returned"); assert_eq!( - post.media_processing_state.as_deref(), + history_post.id, post_id, + "the created post should be returned" + ); + assert_eq!( + history_post.media_processing_state.as_deref(), Some("pending"), "media processing state should be decoded" ); assert_eq!( - post.media_processing_error.as_deref(), + history_post.media_processing_error.as_deref(), Some("transcoding"), "media processing error should be decoded" ); diff --git a/src/db/banners.rs b/src/db/banners.rs index 9acd3ade..1304e68a 100644 --- a/src/db/banners.rs +++ b/src/db/banners.rs @@ -111,7 +111,7 @@ pub fn next_banner_sort_order( params![board_id], |row| row.get(0), )?, - _ => conn.query_row( + BannerScope::Global | BannerScope::Home => conn.query_row( "SELECT COALESCE(MAX(sort_order) + 1, 1) FROM banner_assets WHERE scope_type = ?1", @@ -219,7 +219,8 @@ pub fn delete_banner_asset(conn: &rusqlite::Connection, banner_id: i64) -> Resul conn.execute( "DELETE FROM banner_assets WHERE id = ?1", params![banner_id], - )?; + ) + .map(|_affected_rows| ())?; Ok(asset) } @@ -235,7 +236,8 @@ pub fn delete_board_banner_assets( conn.execute( "DELETE FROM banner_assets WHERE scope_type = 'board' AND board_id = ?1", params![board_id], - )?; + ) + .map(|_affected_rows| ())?; Ok(assets) } @@ -275,10 +277,10 @@ pub fn move_banner_asset( .ok_or_else(|| anyhow::anyhow!("Banner id {banner_id} not found"))?; let swap_with = if move_up { index.checked_sub(1) - } else if index + 1 < ordered_ids.len() { - Some(index + 1) } else { - None + index + .checked_add(1) + .filter(|next| *next < ordered_ids.len()) }; let Some(target_index) = swap_with else { tx.commit()?; @@ -289,9 +291,13 @@ pub fn move_banner_asset( let mut update = tx.prepare_cached("UPDATE banner_assets SET sort_order = ?1 WHERE id = ?2")?; for (position, id) in ordered_ids.iter().enumerate() { - let sort_order = - i64::try_from(position).context("banner sort_order must fit in i64")? + 1; - update.execute(params![sort_order, id])?; + let sort_order = i64::try_from(position) + .ok() + .and_then(|value| value.checked_add(1)) + .context("banner sort_order must fit in i64")?; + update + .execute(params![sort_order, id]) + .map(|_affected_rows| ())?; } } tx.commit()?; diff --git a/src/db/boards.rs b/src/db/boards.rs index 1427ebfc..25fbc4fc 100644 --- a/src/db/boards.rs +++ b/src/db/boards.rs @@ -142,9 +142,13 @@ fn normalize_board_group_order( let mut update = conn.prepare_cached("UPDATE boards SET display_order = ?1 WHERE id = ?2")?; for (position, board_id) in ordered_ids.iter().enumerate() { - let display_order = - i64::try_from(position).context("board display_order index must fit in i64")? + 1; - update.execute(params![display_order, board_id])?; + let display_order = i64::try_from(position) + .ok() + .and_then(|value| value.checked_add(1)) + .context("board display_order index must fit in i64")?; + update + .execute(params![display_order, board_id]) + .map(|_affected_rows| ())?; } Ok(()) } @@ -174,7 +178,8 @@ pub fn set_site_setting(conn: &rusqlite::Connection, key: &str, value: &str) -> ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], ) - .context("Failed to upsert site setting")?; + .context("Failed to upsert site setting") + .map(|_affected_rows| ())?; Ok(()) } @@ -410,13 +415,11 @@ pub fn count_new_threads_for_boards( return Ok(HashMap::new()); } - let values_sql = markers - .iter() - .enumerate() - .map(|(index, _)| { - let base = index * 3; - format!("(?{}, ?{}, ?{})", base + 1, base + 2, base + 3) - }) + let parameter_count = markers + .len() + .checked_mul(3) + .context("board marker parameter count overflow")?; + let values_sql = std::iter::repeat_n("(?, ?, ?)", markers.len()) .collect::>() .join(", "); let sql = format!( @@ -437,7 +440,7 @@ pub fn count_new_threads_for_boards( GROUP BY t.board_id" ); - let mut params = Vec::with_capacity(markers.len() * 3); + let mut params = Vec::with_capacity(parameter_count); for marker in markers { params.push(rusqlite::types::Value::Integer(marker.board_id)); params.push(rusqlite::types::Value::Integer( @@ -456,7 +459,7 @@ pub fn count_new_threads_for_boards( let mut counts = HashMap::new(); for row in rows { let (board_id, count) = row?; - counts.insert(board_id, count); + let _previous_value = counts.insert(board_id, count); } Ok(counts) } @@ -486,13 +489,11 @@ pub fn count_new_replies_for_boards( return Ok(HashMap::new()); } - let values_sql = markers - .iter() - .enumerate() - .map(|(index, _)| { - let base = index * 2; - format!("(?{}, ?{})", base + 1, base + 2) - }) + let parameter_count = markers + .len() + .checked_mul(2) + .context("reply marker parameter count overflow")?; + let values_sql = std::iter::repeat_n("(?, ?)", markers.len()) .collect::>() .join(", "); let sql = format!( @@ -506,7 +507,7 @@ pub fn count_new_replies_for_boards( GROUP BY t.board_id" ); - let mut params = Vec::with_capacity(markers.len() * 2); + let mut params = Vec::with_capacity(parameter_count); for marker in markers { params.push(rusqlite::types::Value::Integer(marker.thread_id)); params.push(rusqlite::types::Value::Integer( @@ -523,7 +524,7 @@ pub fn count_new_replies_for_boards( for row in rows { let (board_id, count) = row?; if count > 0 { - counts.insert(board_id, count); + let _previous_value = counts.insert(board_id, count); } } Ok(counts) @@ -657,7 +658,7 @@ pub fn move_board(conn: &mut rusqlite::Connection, id: i64, move_up: bool) -> Re let board_nsfw: bool = tx.query_row( "SELECT nsfw FROM boards WHERE id = ?1", params![id], - |row| row.get::<_, i32>(0).map(|value| value != 0), + |row| row.get::<_, i32>(0).map(|value| value != 0_i32), )?; let mut stmt = tx.prepare_cached(&format!( "SELECT id FROM boards WHERE nsfw = ?1 ORDER BY {BOARD_GROUP_ORDER_SQL}" @@ -674,10 +675,10 @@ pub fn move_board(conn: &mut rusqlite::Connection, id: i64, move_up: bool) -> Re let swap_with = if move_up { index.checked_sub(1) - } else if index + 1 < ordered_ids.len() { - Some(index + 1) } else { - None + index + .checked_add(1) + .filter(|next| *next < ordered_ids.len()) }; let Some(target_index) = swap_with else { @@ -690,9 +691,13 @@ pub fn move_board(conn: &mut rusqlite::Connection, id: i64, move_up: bool) -> Re { let mut update = tx.prepare_cached("UPDATE boards SET display_order = ?1 WHERE id = ?2")?; for (position, board_id) in ordered_ids.iter().enumerate() { - let display_order = - i64::try_from(position).context("board display_order index must fit in i64")? + 1; - update.execute(params![display_order, board_id])?; + let display_order = i64::try_from(position) + .ok() + .and_then(|value| value.checked_add(1)) + .context("board display_order index must fit in i64")?; + update + .execute(params![display_order, board_id]) + .map(|_affected_rows| ())?; } } tx.commit()?; @@ -752,7 +757,7 @@ pub fn update_board_settings( let current_nsfw: bool = tx.query_row( "SELECT nsfw FROM boards WHERE id = ?1", params![id], - |row| row.get::<_, i32>(0).map(|value| value != 0), + |row| row.get::<_, i32>(0).map(|value| value != 0_i32), )?; let affected = if current_nsfw == nsfw { @@ -1169,14 +1174,16 @@ mod tests { "INSERT INTO boards (id, short_name, name, created_at) VALUES (1, 'test', 'Test', 1_700_000_000)", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO threads (id, board_id, subject, archived) VALUES (1, 1, 'visible one', 0), (2, 1, 'visible two', 0), (3, 1, 'archived', 1)", [], - )?; + ) + .map(|_affected_rows| ())?; let stats = get_all_boards_with_stats(&conn)?; let board_stats = stats @@ -1230,11 +1237,13 @@ mod tests { conn.execute( "INSERT INTO boards (id, short_name, name) VALUES (1, 'test', 'Test')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO threads (id, board_id, subject) VALUES (1, 1, 'test thread')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO posts ( @@ -1250,7 +1259,8 @@ mod tests { (4, 1, 1, 'text post', '

text

', 'tok4', 0, NULL, NULL, NULL, NULL, NULL)", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "UPDATE posts SET audio_file_path = 'test/track.flac', @@ -1259,7 +1269,8 @@ mod tests { audio_mime_type = 'audio/flac' WHERE id = 2", [], - )?; + ) + .map(|_affected_rows| ())?; let stats = get_site_stats(&conn)?; assert_eq!(stats.total_posts, 4, "all posts should be counted"); @@ -1288,13 +1299,15 @@ mod tests { conn.execute( "INSERT INTO boards (id, short_name, name) VALUES (1, 'test', 'Test')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO threads (id, board_id, subject, archived) VALUES (1, 1, 'live thread', 0), (2, 1, 'archived thread', 1)", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO posts ( id, thread_id, board_id, body, body_html, deletion_token, is_op, @@ -1303,7 +1316,7 @@ mod tests { (1, 1, 1, 'live', '

live

', 'tok1', 0, 'live.webp', 'live.webp', 100), (2, 2, 1, 'archived', '

archived

', 'tok2', 0, 'archived.webp', 'archived.webp', 900)", [], - )?; + ).map(|_affected_rows| ())?; let stats = get_site_stats(&conn)?; assert_eq!( @@ -1371,7 +1384,8 @@ mod tests { true, true, true, - )?; + ) + .map(|_created_id| ())?; let board = get_board_by_short(&conn, "audio")?.context("audio board should exist")?; assert!(board.allow_images, "image uploads should be enabled"); @@ -1411,7 +1425,7 @@ mod tests { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - create_board(&conn, "fresh", "Fresh", "", false)?; + create_board(&conn, "fresh", "Fresh", "", false).map(|_created_id| ())?; let board = get_board_by_short(&conn, "fresh")?.context("fresh board should exist")?; assert_eq!( @@ -1485,7 +1499,8 @@ mod tests { conn.execute( "INSERT INTO threads (id, board_id, subject) VALUES (1, ?1, 'delete me')", rusqlite::params![board_id], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO posts ( id, thread_id, board_id, body, body_html, deletion_token, is_op, @@ -1494,7 +1509,8 @@ mod tests { (1, 1, ?1, 'body', '

body

', 'tok', 1, 'gone/file.webp', 'file.webp', 4, 'gone/thumbs/file.webp')", rusqlite::params![board_id], - )?; + ) + .map(|_affected_rows| ())?; let deleted = delete_board(&conn, board_id)?; assert_eq!( diff --git a/src/db/fs_ops.rs b/src/db/fs_ops.rs index db6798fa..8f7954b1 100644 --- a/src/db/fs_ops.rs +++ b/src/db/fs_ops.rs @@ -47,7 +47,8 @@ pub fn insert_pending_fs_op( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES (?1, ?2, ?3)", rusqlite::params![op.id, op.kind, op.payload_json], ) - .context("Insert pending_fs_op failed")?; + .context("Insert pending_fs_op failed") + .map(|_affected_rows| ())?; Ok(()) } @@ -60,7 +61,8 @@ pub fn delete_pending_fs_op(conn: &rusqlite::Connection, id: &str) -> Result<()> "DELETE FROM pending_fs_ops WHERE id = ?1", rusqlite::params![id], ) - .with_context(|| format!("Delete pending_fs_op {id} failed"))?; + .with_context(|| format!("Delete pending_fs_op {id} failed")) + .map(|_affected_rows| ())?; Ok(()) } diff --git a/src/db/migrations.rs b/src/db/migrations.rs index aca6a4bc..3e6871f0 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -57,7 +57,8 @@ pub(super) fn stamp_schema_version_in_transaction(conn: &rusqlite::Connection) - "INSERT INTO schema_version (version) VALUES (?1)", rusqlite::params![BASELINE_SCHEMA_VERSION], ) - .with_context(|| format!("Failed to set schema_version to {BASELINE_SCHEMA_VERSION}"))?; + .with_context(|| format!("Failed to set schema_version to {BASELINE_SCHEMA_VERSION}")) + .map(|_affected_rows| ())?; Ok(()) } diff --git a/src/db/mod.rs b/src/db/mod.rs index 3a43a0af..c3d16b84 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -224,7 +224,8 @@ pub fn paths_safe_to_delete( "DELETE FROM file_hashes WHERE file_path = ?1", params![file_path], ) - .context("Delete stale file_hashes row failed")?; + .context("Delete stale file_hashes row failed") + .map(|_affected_rows| ())?; } } } diff --git a/src/db/pool.rs b/src/db/pool.rs index d22aa4de..0cbde1f0 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -104,7 +104,7 @@ pub fn first_run_check(pool: &DbPool) -> Result<()> { if board_count == 0 { tracing::info!( target: "startup", - boards = 0, + boards = 0_i32, admins = admin_count, "No boards found — create boards via admin panel or: rustchan-cli admin create-board" ); @@ -146,7 +146,7 @@ mod tests { impl Drop for ReleaseInitializers { fn drop(&mut self) { *self.0.released.lock() = true; - self.0.wake.notify_all(); + let _waiting_threads = self.0.wake.notify_all(); } } @@ -195,7 +195,7 @@ mod tests { #[test] fn every_pool_connection_enforces_wal_full_and_foreign_keys() -> Result<()> { let pool = super::init_test_pool()?; - let connections = (0..4) + let connections = (0_i32..4_i32) .map(|_| pool.get()) .collect::, _>>()?; for conn in connections { @@ -235,7 +235,7 @@ mod tests { #[test] fn exhausted_pool_returns_a_bounded_retryable_error() -> Result<()> { let pool = super::init_test_pool()?; - let held = (0..4) + let held = (0_i32..4_i32) .map(|_| pool.get()) .collect::, _>>()?; let started = std::time::Instant::now(); @@ -267,10 +267,12 @@ mod tests { let pool = super::init_test_pool()?; let writer = pool.get()?; writer.execute_batch("BEGIN IMMEDIATE")?; - writer.execute( - "INSERT INTO site_settings(key,value) VALUES('uncommitted','value')", - [], - )?; + writer + .execute( + "INSERT INTO site_settings(key,value) VALUES('uncommitted','value')", + [], + ) + .map(|_affected_rows| ())?; let reader = pool.get()?; let visible: i64 = reader.query_row( "SELECT COUNT(*) FROM site_settings WHERE key='uncommitted'", @@ -302,10 +304,12 @@ mod tests { reader.is_autocommit(), "busy failure left a transaction active" ); - reader.execute( - "INSERT INTO site_settings(key,value) VALUES('recovered','value')", - [], - )?; + reader + .execute( + "INSERT INTO site_settings(key,value) VALUES('recovered','value')", + [], + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -313,10 +317,10 @@ mod tests { fn active_read_snapshot_bounds_checkpoint_and_wal_backup_stays_complete() -> Result<()> { let pool = super::init_test_pool()?; let writer = pool.get()?; - crate::db::create_board(&writer, "wal", "WAL", "", false)?; + crate::db::create_board(&writer, "wal", "WAL", "", false).map(|_created_id| ())?; let reader = pool.get()?; reader.execute_batch("BEGIN; SELECT COUNT(*) FROM boards;")?; - crate::db::create_board(&writer, "next", "Next", "", false)?; + crate::db::create_board(&writer, "next", "Next", "", false).map(|_created_id| ())?; let (log, checkpointed, busy) = crate::db::run_wal_checkpoint(&writer)?; anyhow::ensure!( busy == 1 && checkpointed < log, @@ -324,19 +328,24 @@ mod tests { ); let directory = tempfile::tempdir()?; let path = directory.path().join("backup.sqlite3"); - writer.execute( - "VACUUM INTO ?1", - [path - .to_str() - .ok_or_else(|| anyhow::anyhow!("backup path"))?], - )?; + writer + .execute( + "VACUUM INTO ?1", + [path + .to_str() + .ok_or_else(|| anyhow::anyhow!("backup path"))?], + ) + .map(|_affected_rows| ())?; let snapshot = rusqlite::Connection::open(&path)?; let boards: i64 = snapshot.query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0))?; anyhow::ensure!(boards == 2, "WAL snapshot missed a committed board"); crate::db::verify_database_schema(&snapshot)?; reader.execute_batch("ROLLBACK")?; - let (_, _, busy) = crate::db::run_wal_checkpoint(&writer)?; - anyhow::ensure!(busy == 0, "checkpoint did not recover after reader release"); + let (_, _, recovered_busy) = crate::db::run_wal_checkpoint(&writer)?; + anyhow::ensure!( + recovered_busy == 0, + "checkpoint did not recover after reader release" + ); Ok(()) } } diff --git a/src/db/posts.rs b/src/db/posts.rs index 0ae740f2..7cd2dcdf 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -121,7 +121,7 @@ pub struct RecentBackgroundJob { /// 5 subject 13 `mime_type` 21 `audio_mime_type` /// 6 body 14 `created_at` 22 `edited_at` /// 7 `body_html` 15 `deletion_token` 23 `media_processing_state` -/// 24 `media_processing_error` +/// 24 `media_processing_error`. /// /// # Errors /// Returns an error if the database operation fails. @@ -225,10 +225,7 @@ pub fn get_posts_by_ids_in_thread( return Ok(Vec::new()); } - let placeholders = post_ids - .iter() - .enumerate() - .map(|(index, _)| format!("?{}", index + 3)) + let placeholders = std::iter::repeat_n("?", post_ids.len()) .collect::>() .join(", "); let sql = format!( @@ -265,13 +262,9 @@ pub fn get_preview_posts_for_threads( return Ok(HashMap::new()); } - let placeholders = thread_ids - .iter() - .enumerate() - .map(|(index, _)| format!("?{}", index + 1)) + let placeholders = std::iter::repeat_n("?", thread_ids.len()) .collect::>() .join(", "); - let limit_param = thread_ids.len() + 1; let sql = format!( "SELECT {POST_SELECT_COLUMNS} FROM ( @@ -283,7 +276,7 @@ pub fn get_preview_posts_for_threads( FROM posts WHERE is_op = 0 AND thread_id IN ({placeholders}) ) - WHERE preview_rank <= ?{limit_param} + WHERE preview_rank <= ? ORDER BY thread_id ASC, created_at ASC, id ASC" ); let mut stmt = conn.prepare_cached(&sql)?; @@ -382,7 +375,7 @@ pub fn get_post_submission( Ok(PostSubmissionRecord { thread_id: row.get(0)?, post_id: row.get(1)?, - is_thread: row.get::<_, i32>(2)? != 0, + is_thread: row.get::<_, i32>(2)? != 0_i32, }) }, ) @@ -419,13 +412,15 @@ pub fn record_post_submission( i32::from(is_thread) ], ) - .context("Failed to record unique post submission token")?; + .context("Failed to record unique post submission token") + .map(|_affected_rows| ())?; conn.execute( "DELETE FROM post_submissions WHERE created_at < unixepoch() - 604800", [], ) - .context("Failed to prune expired post submission tokens")?; + .context("Failed to prune expired post submission tokens") + .map(|_affected_rows| ())?; Ok(()) } @@ -459,7 +454,8 @@ pub(super) fn create_poll_inner( text, i64::try_from(i).context("poll option index overflow")? ]) - .context("Failed to insert poll option")?; + .context("Failed to insert poll option") + .map(|_affected_rows| ())?; } Ok(poll_id) @@ -540,7 +536,7 @@ fn delete_post_reply_in_tx( .query_row(params![post_id], |r| { Ok(( r.get::<_, i64>(0)?, - r.get::<_, i32>(1)? != 0, + r.get::<_, i32>(1)? != 0_i32, r.get::<_, Option>(2)?, r.get::<_, Option>(3)?, r.get::<_, Option>(4)?, @@ -646,7 +642,12 @@ pub fn post_thread_allows_self_actions( JOIN threads t ON t.id = p.thread_id WHERE p.id = ?1", params![post_id], - |row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)), + |row| { + Ok(( + row.get::<_, i32>(0)? != 0_i32, + row.get::<_, i32>(1)? != 0_i32, + )) + }, ) .optional()?; Ok(flags.is_some_and(|(locked, archived)| !locked && !archived)) @@ -681,11 +682,11 @@ pub fn self_delete_post( |r| { Ok(( r.get::<_, i64>(0)?, - r.get::<_, i32>(1)? != 0, + r.get::<_, i32>(1)? != 0_i32, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, - r.get::<_, i32>(4)? != 0, - r.get::<_, i32>(5)? != 0, + r.get::<_, i32>(4)? != 0_i32, + r.get::<_, i32>(5)? != 0_i32, )) }, ) @@ -784,8 +785,8 @@ pub fn edit_post( Ok(( r.get(0)?, r.get(1)?, - r.get::<_, i32>(2)? != 0, - r.get::<_, i32>(3)? != 0, + r.get::<_, i32>(2)? != 0_i32, + r.get::<_, i32>(3)? != 0_i32, )) }, ) @@ -811,7 +812,8 @@ pub fn edit_post( conn.execute( "UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4", params![new_body, new_body_html, now, post_id], - )?; + ) + .map(|_affected_rows| ())?; // Belt-and-suspenders: confirm the row was actually written. Ok(conn.changes() > 0) @@ -1013,7 +1015,8 @@ pub fn record_file_hash( "INSERT OR REPLACE INTO file_hashes (sha256, file_path, thumb_path, mime_type) VALUES (?1, ?2, ?3, ?4)", params![sha256, file_path, thumb_path, mime_type], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -1328,7 +1331,8 @@ pub fn persist_media_job( updated_at = unixepoch() WHERE id = ?1 AND status IN ('pending', 'running')", params![id], - )?; + ) + .map(|_affected_rows| ())?; if status == "pending" { pending_jobs_resolved = pending_jobs_resolved.saturating_add(1); } @@ -1353,7 +1357,8 @@ pub fn persist_media_job( expected_source, MEDIA_PROCESSING_FAILED ], - )?; + ) + .map(|_affected_rows| ())?; } return Ok(MediaJobSchedule { job_id, @@ -1445,7 +1450,8 @@ pub fn reject_media_job_at_capacity( expected_source, MEDIA_PROCESSING_FAILED ], - )?; + ) + .map(|_affected_rows| ())?; Ok(None) })(); match result { @@ -1523,7 +1529,8 @@ pub(crate) fn persist_thread_prune_intent_in_tx( WHERE id = ?1 AND status = 'pending'", params![job_id, payload], ) - .context("Refresh coalesced board prune intent failed")?; + .context("Refresh coalesced board prune intent failed") + .map(|_affected_rows| ())?; for duplicate_id in duplicate_ids { conn.execute( @@ -1534,7 +1541,8 @@ pub(crate) fn persist_thread_prune_intent_in_tx( WHERE id = ?1 AND status = 'pending'", params![duplicate_id], ) - .context("Resolve duplicate legacy board prune intent failed")?; + .context("Resolve duplicate legacy board prune intent failed") + .map(|_affected_rows| ())?; } return Ok(ThreadPruneSchedule { @@ -1866,7 +1874,8 @@ pub fn recover_interrupted_background_jobs( WHERE id = ?1 AND media_processing_state IN (?2, 'running')", params![post_id, MEDIA_PROCESSING_PENDING], - )?; + ) + .map(|_affected_rows| ())?; } } } @@ -1972,16 +1981,24 @@ fn interrupted_media_job_disposition( media_post_id: None, }); }; - let post_id = match &target { - InterruptedMediaTarget::Video { post_id, .. } - | InterruptedMediaTarget::Audio { post_id, .. } => *post_id, + let target_post_id = match &target { + InterruptedMediaTarget::Video { + post_id, + source_path: _, + expected_output_path: _, + } + | InterruptedMediaTarget::Audio { + post_id, + source_path: _, + expected_thumb_path: _, + } => *post_id, }; let post = conn .query_row( "SELECT file_path, thumb_path, media_processing_state FROM posts WHERE id = ?1", - rusqlite::params![post_id], + rusqlite::params![target_post_id], |row| { Ok(( row.get::<_, Option>(0)?, @@ -2161,7 +2178,8 @@ pub fn set_post_media_processing_state( media_processing_error = ?2 WHERE id = ?3", params![normalized_state, normalized_error, post_id], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -2288,7 +2306,8 @@ pub fn complete_media_job_without_output_in_tx( WHERE id = ?1 AND file_path = ?2 AND media_processing_state IN (?3, 'running')", params![post_id, expected_source, MEDIA_PROCESSING_PENDING], - )?; + ) + .map(|_affected_rows| ())?; complete_job(conn, job_id) } @@ -2345,7 +2364,8 @@ pub fn fail_media_job_in_tx( WHERE id = ?2 AND file_path = ?3 AND media_processing_state IN (?1, 'running')", params![MEDIA_PROCESSING_PENDING, post_id, expected_source], - )?; + ) + .map(|_affected_rows| ())?; } JobFailureState::PermanentlyFailed => { let error: String = error.trim().chars().take(512).collect(); @@ -2361,7 +2381,8 @@ pub fn fail_media_job_in_tx( expected_source, MEDIA_PROCESSING_PENDING ], - )?; + ) + .map(|_affected_rows| ())?; } } Ok(Some(failure_state)) @@ -2510,7 +2531,8 @@ pub fn finish_waveform_media_job_in_tx( "UPDATE file_hashes SET thumb_path = ?1 WHERE sha256 = ?2 AND file_path = ?3", params![output_path, source_sha256, expected_source], - )?; + ) + .map(|_affected_rows| ())?; let cleanup = if previous_thumb .as_deref() .is_some_and(|path| path != output_path) @@ -2591,7 +2613,8 @@ pub fn replace_transcoded_media( conn.execute( "DELETE FROM file_hashes WHERE file_path = ?1", params![old_path], - )?; + ) + .map(|_affected_rows| ())?; record_file_hash(conn, new_sha256, new_path, &thumb_path, new_mime)?; Ok(()) })(); @@ -2646,7 +2669,8 @@ pub fn delete_file_hash_by_path(conn: &rusqlite::Connection, file_path: &str) -> conn.execute( "DELETE FROM file_hashes WHERE file_path = ?1", params![file_path], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -2676,7 +2700,7 @@ mod tests { } fn seed_search_post(conn: &Connection, board_short: &str, body: &str) -> Result { - create_board(conn, board_short, board_short, "", false)?; + create_board(conn, board_short, board_short, "", false).map(|_created_id| ())?; let board = get_board_by_short(conn, board_short)?.context("seeded search board should exist")?; let post = NewPost { @@ -2708,7 +2732,7 @@ mod tests { } fn seed_media_post(conn: &Connection, board_short: &str, file_path: &str) -> Result { - create_board(conn, board_short, board_short, "", false)?; + create_board(conn, board_short, board_short, "", false).map(|_created_id| ())?; let board = get_board_by_short(conn, board_short)?.context("seeded media board should exist")?; let post = NewPost { @@ -2776,7 +2800,8 @@ mod tests { insert_background_job(&conn, "spam_check", payload, "failed", 3, Some("older"))?; let newer = insert_background_job(&conn, "thread_prune", payload, "failed", 2, Some("newer"))?; - insert_background_job(&conn, "spam_check", payload, "pending", 0, None)?; + insert_background_job(&conn, "spam_check", payload, "pending", 0, None) + .map(|_created_id| ())?; let jobs = recent_background_jobs(&conn, "failed", 1)?; @@ -2810,7 +2835,8 @@ mod tests { fn failed_background_job_acknowledgement_preserves_history() -> Result<()> { let conn = test_conn()?; let payload = r#"{"t":"SpamCheck","d":{"post_id":1,"ip_hash":"hash","body_len":5}}"#; - insert_background_job(&conn, "spam_check", payload, "failed", 3, Some("older"))?; + insert_background_job(&conn, "spam_check", payload, "failed", 3, Some("older")) + .map(|_created_id| ())?; let acknowledged = insert_background_job(&conn, "thread_prune", payload, "failed", 3, Some("newer"))?; @@ -2842,7 +2868,8 @@ mod tests { "failed", 3, Some("new failure"), - )?; + ) + .map(|_created_id| ())?; assert_eq!( background_job_summary(&conn)?.failed, 1, @@ -2911,7 +2938,7 @@ mod tests { )] fn search_posts_reads_joined_fts_rows_without_ambiguous_columns() -> Result<()> { let conn = test_conn()?; - seed_search_post(&conn, "tech", "rust search body")?; + seed_search_post(&conn, "tech", "rust search body").map(|_created_id| ())?; let board = get_board_by_short(&conn, "tech")?.context("tech board should exist")?; let posts = search_posts(&conn, board.id, "rust", 20, 0)?; @@ -2932,8 +2959,8 @@ mod tests { )] fn search_posts_stays_scoped_to_board() -> Result<()> { let conn = test_conn()?; - seed_search_post(&conn, "tech", "shared rust term")?; - seed_search_post(&conn, "meta", "shared rust term")?; + seed_search_post(&conn, "tech", "shared rust term").map(|_created_id| ())?; + seed_search_post(&conn, "meta", "shared rust term").map(|_created_id| ())?; let tech = get_board_by_short(&conn, "tech")?.context("tech board should exist")?; let posts = search_posts(&conn, tech.id, "rust", 20, 0)?; @@ -2960,7 +2987,7 @@ mod tests { )] fn search_posts_matches_case_insensitively() -> Result<()> { let conn = test_conn()?; - seed_search_post(&conn, "tech", "AI will find this")?; + seed_search_post(&conn, "tech", "AI will find this").map(|_created_id| ())?; let board = get_board_by_short(&conn, "tech")?.context("tech board should exist")?; let posts = search_posts(&conn, board.id, "ai", 20, 0)?; @@ -3010,7 +3037,8 @@ mod tests { SELECT ?1,?2,CASE WHEN x%101=0 THEN 'common needle' ELSE 'common' END, 'body','delete',1700000000+x%7 FROM n", params![thread, board.id], - )?; + ) + .map(|_affected_rows| ())?; for term in ["common", "needle", "absent"] { let total: i64 = conn.query_row( "SELECT COUNT(*) FROM posts WHERE board_id=?1 AND body LIKE ?2", @@ -3045,7 +3073,8 @@ mod tests { conn.execute( "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", [], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -3349,7 +3378,8 @@ mod tests { "running", 1, Some("old error"), - )?; + ) + .map(|_created_id| ())?; let recovery = recover_interrupted_background_jobs(&conn)?; @@ -3387,7 +3417,8 @@ mod tests { conn.execute( "UPDATE posts SET file_path = 'applied/video.webm' WHERE id = ?1", rusqlite::params![post_id], - )?; + ) + .map(|_affected_rows| ())?; let payload = format!( r#"{{"t":"VideoTranscode","d":{{"post_id":{post_id},"file_path":"applied/video.mp4","board_short":"applied"}}}}"# ); @@ -3450,7 +3481,8 @@ mod tests { media_processing_state = ?2 WHERE id = ?1", rusqlite::params![post_id, MEDIA_PROCESSING_PENDING], - )?; + ) + .map(|_affected_rows| ())?; let payload = format!( r#"{{"t":"VideoTranscode","d":{{"post_id":{post_id},"file_path":"newer/old.mp4","board_short":"newer"}}}}"# ); @@ -3500,7 +3532,8 @@ mod tests { media_processing_state = 'running' WHERE id = ?1", rusqlite::params![post_id], - )?; + ) + .map(|_affected_rows| ())?; let payload = format!( r#"{{"t":"AudioWaveform","d":{{"post_id":{post_id},"file_path":"audio/track.mp3","board_short":"audio"}}}}"# ); @@ -3555,7 +3588,7 @@ mod tests { let payload = r#"{"t":"SpamCheck","d":{"post_id":42,"ip_hash":"hash","body_len":5}}"#; let job_id = insert_background_job(&conn, "spam_check", payload, "running", 1, None)?; - recover_interrupted_background_jobs(&conn)?; + recover_interrupted_background_jobs(&conn).map(|_operation_summary| ())?; let claimed = claim_next_job(&conn)?.context("recovered job should be claimable")?; assert_eq!( @@ -3825,7 +3858,7 @@ mod tests { "reply creation should increment the thread count" ); - super::delete_post(&conn, reply_id)?; + super::delete_post(&conn, reply_id).map(|_operation_summary| ())?; let after_count: i64 = conn.query_row( "SELECT reply_count FROM threads WHERE id = ?1", @@ -3996,11 +4029,12 @@ mod tests { deletion_token: "reply-token".to_owned(), is_op: false, }; - create_reply_with_thread_update(&conn, &reply, "", false, None)?; + create_reply_with_thread_update(&conn, &reply, "", false, None).map(|_created_id| ())?; conn.execute( "UPDATE threads SET reply_count = 0 WHERE id = ?1", [thread_id], - )?; + ) + .map(|_affected_rows| ())?; let (outcome, deleted) = self_delete_post(&conn, op_id, "op-token", 60)?; diff --git a/src/db/schema.rs b/src/db/schema.rs index 13c7677a..f5ce1a2f 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -1026,7 +1026,7 @@ fn themes_table_is_legacy_repairable(expected: &TableShape, actual: &TableShape) } let mut actual_indexes = actual.indexes.clone(); - actual_indexes.remove(LEGACY_THEME_SORT_INDEX); + let _previous_value = actual_indexes.remove(LEGACY_THEME_SORT_INDEX); actual_indexes == expected.indexes } @@ -1339,12 +1339,14 @@ fn restore_autoincrement_sequence( .with_context(|| format!("Read rebuilt {table} maximum id failed"))?; let sequence = prior_sequence.max(current_max); conn.execute("DELETE FROM sqlite_sequence WHERE name = ?1", [table]) - .with_context(|| format!("Clear rebuilt {table} AUTOINCREMENT sequence failed"))?; + .with_context(|| format!("Clear rebuilt {table} AUTOINCREMENT sequence failed")) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO sqlite_sequence (name, seq) VALUES (?1, ?2)", rusqlite::params![table, sequence], ) - .with_context(|| format!("Restore rebuilt {table} AUTOINCREMENT sequence failed"))?; + .with_context(|| format!("Restore rebuilt {table} AUTOINCREMENT sequence failed")) + .map(|_affected_rows| ())?; Ok(()) } @@ -1642,7 +1644,8 @@ fn schema_version_metadata_issues(conn: &rusqlite::Connection) -> Result Result<()> { conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) - .context("Database did not answer a readiness query")?; + .context("Database did not answer a readiness query") + .map(|_readiness_value| ())?; let version: String = conn .query_row( "SELECT CAST(version AS TEXT) FROM schema_version", @@ -1684,7 +1687,7 @@ fn schema_shape(conn: &rusqlite::Connection) -> Result { let mut tables = BTreeMap::new(); for (name, object) in &objects { if object.kind == "table" { - tables.insert(name.clone(), table_shape(conn, name)?); + let _previous_value = tables.insert(name.clone(), table_shape(conn, name)?); } } Ok(SchemaShape { objects, tables }) @@ -1720,7 +1723,7 @@ fn schema_objects(conn: &rusqlite::Connection) -> Result Result Vec { let mut current = String::new(); let mut chars = sql.chars().peekable(); - let flush_current = |current: &mut String, tokens: &mut Vec| { - if !current.is_empty() { - tokens.push(std::mem::take(current)); + let flush_current = |fragment: &mut String, output: &mut Vec| { + if !fragment.is_empty() { + output.push(std::mem::take(fragment)); } }; @@ -2160,28 +2164,36 @@ fn check_constraints_from_tokens(tokens: &[String]) -> BTreeSet { while let Some(token) = tokens.get(index) { let next_index = index.saturating_add(1); if token != "check" || tokens.get(next_index).map(String::as_str) != Some("(") { - index += 1; + index = next_index; continue; } let start = next_index; - let mut depth = 0_i64; + let mut depth = 0_usize; let mut end = start; while let Some(expression_token) = tokens.get(end) { match expression_token.as_str() { - "(" => depth += 1, + "(" => { + let Some(next_depth) = depth.checked_add(1) else { + break; + }; + depth = next_depth; + } ")" => { - depth -= 1; + let Some(next_depth) = depth.checked_sub(1) else { + break; + }; + depth = next_depth; if depth == 0 { if let Some(expression) = tokens.get(start..=end) { - constraints.insert(expression.join(" ")); + let _inserted = constraints.insert(expression.join(" ")); } break; } } _ => {} } - end += 1; + end = end.saturating_add(1); } index = end.saturating_add(1); } @@ -2251,7 +2263,8 @@ mod tests { conn.execute( "INSERT INTO schema_version (version) VALUES (?1)", [version], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -2419,22 +2432,25 @@ mod tests { let results = std::thread::scope(|scope| { let mut handles = Vec::new(); - for _ in 0..2 { + for _ in 0_i32..2_i32 { let path = database_path.clone(); let barrier = std::sync::Arc::clone(&barrier); handles.push(scope.spawn(move || -> Result<()> { let conn = rusqlite::Connection::open(path)?; conn.busy_timeout(std::time::Duration::from_secs(5))?; - barrier.wait(); + let _barrier_state = barrier.wait(); install_or_migrate_schema(&conn) })); } handles .into_iter() .map(|handle| { - handle - .join() - .map_err(|_| anyhow::anyhow!("schema installer thread panicked"))? + handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "schema installer thread panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })? }) .collect::>>() })?; @@ -2464,11 +2480,13 @@ mod tests { conn.execute( "INSERT INTO schema_version (version) VALUES (?1)", [baseline_schema_version()], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random')", [], - )?; + ) + .map(|_affected_rows| ())?; normalize_database_schema_version(&conn)?; @@ -2629,7 +2647,8 @@ mod tests { ) VALUES (1, 'b', 'Random', 0, 1, 0, 1, 'override', 'public')", [], - )?; + ) + .map(|_affected_rows| ())?; rebuild_boards_with_historical_v41_shape(&conn)?; conn.execute_batch( "CREATE INDEX idx_themes_enabled_sort @@ -2752,7 +2771,8 @@ mod tests { fn unknown_schema_object_fails_closed() -> Result<()> { let conn = rusqlite::Connection::open_in_memory()?; install_or_migrate_schema(&conn)?; - conn.execute("CREATE TABLE operator_notes (body TEXT)", [])?; + conn.execute("CREATE TABLE operator_notes (body TEXT)", []) + .map(|_affected_rows| ())?; let error = normalize_database_schema_version(&conn) .err() @@ -2778,7 +2798,8 @@ mod tests { conn.execute( "INSERT INTO boards (short_name, name) VALUES ('fresh', 'Fresh Board')", [], - )?; + ) + .map(|_affected_rows| ())?; let flags: (i64, i64, i64, i64, i64) = conn .query_row( @@ -2850,17 +2871,20 @@ mod tests { conn.execute( "INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'thread')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "INSERT INTO posts (id, thread_id, board_id, body, body_html, deletion_token, is_op) VALUES (100, 10, 1, 'body', 'body', 'token', 1)", [], - )?; + ) + .map(|_affected_rows| ())?; for sql in [ "UPDATE boards SET nsfw = 2 WHERE id = 1", @@ -2911,7 +2935,8 @@ mod tests { "INSERT INTO background_jobs (id, job_type, payload) VALUES (1, 'spam_check', '{}')", [], - )?; + ) + .map(|_affected_rows| ())?; assert_sql_rejected( &conn, "UPDATE background_jobs SET status = 'stuck' WHERE id = 1", @@ -3086,7 +3111,8 @@ mod tests { "INSERT INTO boards (id, short_name, name, max_threads) VALUES (1, 'b', 'Random', 0)", [], - )?; + ) + .map(|_affected_rows| ())?; let error = normalize_database_schema_version(&conn) .err() @@ -3122,7 +3148,8 @@ mod tests { conn.execute( "INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random')", [], - )?; + ) + .map(|_affected_rows| ())?; let access_trigger: String = conn.query_row( "SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'boards_access_mode_update'", @@ -3133,7 +3160,8 @@ mod tests { conn.execute( "UPDATE boards SET access_mode = 'unknown_mode' WHERE id = 1", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute_batch(&access_trigger)?; conn.execute_batch( "CREATE TABLE schema_version (version TEXT NOT NULL PRIMARY KEY); @@ -3197,7 +3225,8 @@ mod tests { next_id == 501, "a rebuild must not reuse an AUTOINCREMENT id issued before deletion" ); - conn.execute("DELETE FROM boards WHERE id = 1", [])?; + conn.execute("DELETE FROM boards WHERE id = 1", []) + .map(|_affected_rows| ())?; let child_count = conn.query_row("SELECT COUNT(*) FROM threads WHERE id = 10", [], |row| { row.get::<_, i64>(0) diff --git a/src/db/setup.rs b/src/db/setup.rs index d64e902d..bc7e54fc 100644 --- a/src/db/setup.rs +++ b/src/db/setup.rs @@ -128,7 +128,8 @@ pub fn close_reopened_setup(conn: &rusqlite::Connection) -> Result<()> { "DELETE FROM site_settings WHERE key IN (?1, ?2)", params![SETUP_REOPENED_AT_KEY, SETUP_REOPENED_BY_KEY], ) - .context("clear setup reopen marker")?; + .context("clear setup reopen marker") + .map(|_affected_rows| ())?; Ok(()) } @@ -144,7 +145,8 @@ pub fn mark_setup_complete(conn: &rusqlite::Connection) -> Result<()> { ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![SETUP_COMPLETED_AT_KEY, now], ) - .context("write setup completion marker")?; + .context("write setup completion marker") + .map(|_affected_rows| ())?; close_reopened_setup(conn) })(); finish_savepoint( @@ -237,7 +239,7 @@ mod tests { fn admin_without_marker_blocks_setup_as_initialized() -> Result<()> { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - crate::db::create_admin(&conn, "admin", "hash")?; + crate::db::create_admin(&conn, "admin", "hash").map(|_created_id| ())?; let state = setup_state(&conn)?; @@ -265,7 +267,7 @@ mod tests { fn admin_reopen_makes_setup_available_but_admin_authenticated() -> Result<()> { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - crate::db::create_admin(&conn, "admin", "hash")?; + crate::db::create_admin(&conn, "admin", "hash").map(|_created_id| ())?; reopen_setup(&conn, 1)?; let state = setup_state(&conn)?; diff --git a/src/db/themes.rs b/src/db/themes.rs index 4108ad17..3d594215 100644 --- a/src/db/themes.rs +++ b/src/db/themes.rs @@ -119,7 +119,7 @@ pub fn upsert_builtin_themes(conn: &rusqlite::Connection) -> Result<()> { theme.sort_order, ], ) - .context("Failed to upsert built-in theme")?; + .context("Failed to upsert built-in theme").map(|_affected_rows| ())?; } Ok(()) } @@ -155,7 +155,7 @@ pub fn create_custom_theme( custom_css, ], ) - .context("Failed to create custom theme")?; + .context("Failed to create custom theme").map(|_affected_rows| ())?; Ok(()) } @@ -223,13 +223,15 @@ pub fn update_theme( "UPDATE boards SET default_theme = ?1 WHERE lower(default_theme) = lower(?2)", params![new_slug, existing_slug], ) - .context("Failed to update board theme references")?; + .context("Failed to update board theme references") + .map(|_affected_rows| ())?; tx.execute( "UPDATE site_settings SET value = ?1 WHERE key = 'default_theme' AND lower(value) = lower(?2)", params![new_slug, existing_slug], ) - .context("Failed to update site default theme reference")?; + .context("Failed to update site default theme reference") + .map(|_affected_rows| ())?; } tx.commit()?; Ok(()) @@ -266,12 +268,14 @@ pub fn delete_custom_theme(conn: &mut rusqlite::Connection, slug: &str) -> Resul tx.execute( "UPDATE boards SET default_theme = '' WHERE lower(default_theme) = lower(?1)", params![slug], - )?; + ) + .map(|_affected_rows| ())?; tx.execute( "UPDATE site_settings SET value = ?2 WHERE key = 'default_theme' AND lower(value) = lower(?1)", params![slug, crate::theme::HARD_DEFAULT_THEME], - )?; + ) + .map(|_affected_rows| ())?; tx.commit()?; Ok(()) } @@ -547,7 +551,8 @@ mod tests { let pool = crate::db::init_test_pool()?; let mut conn = pool.get()?; let board_short = "thmup"; - crate::db::boards::create_board(&conn, board_short, "Theme Update", "", false)?; + crate::db::boards::create_board(&conn, board_short, "Theme Update", "", false) + .map(|_created_id| ())?; super::create_custom_theme( &conn, "guided-forest", @@ -560,7 +565,8 @@ mod tests { conn.execute( "UPDATE boards SET default_theme = 'guided-forest' WHERE short_name = ?1", params![board_short], - )?; + ) + .map(|_affected_rows| ())?; crate::db::set_site_setting(&conn, "default_theme", "guided-forest")?; super::update_theme( @@ -600,7 +606,8 @@ mod tests { let pool = crate::db::init_test_pool()?; let mut conn = pool.get()?; let board_short = "thmdel"; - crate::db::boards::create_board(&conn, board_short, "Theme Delete", "", false)?; + crate::db::boards::create_board(&conn, board_short, "Theme Delete", "", false) + .map(|_created_id| ())?; super::create_custom_theme( &conn, "guided-forest", @@ -613,7 +620,8 @@ mod tests { conn.execute( "UPDATE boards SET default_theme = 'guided-forest' WHERE short_name = ?1", params![board_short], - )?; + ) + .map(|_affected_rows| ())?; crate::db::set_site_setting(&conn, "default_theme", "guided-forest")?; super::delete_custom_theme(&mut conn, "guided-forest")?; diff --git a/src/db/threads.rs b/src/db/threads.rs index 493c8e45..5dd525e0 100644 --- a/src/db/threads.rs +++ b/src/db/threads.rs @@ -49,7 +49,7 @@ pub struct PollInsert<'a> { /// 1 `t.board_id` 5 t.locked 9 `op.file_path` 13 op.id (`op_id`) /// 2 t.subject 6 t.sticky 10 `op.thumb_path` 14 t.archived /// 3 `t.created_at` 7 `t.reply_count` 11 op.name 15 `image_count` -/// 16 `last_post_at` +/// 16 `last_post_at`. fn map_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Thread { id: row.get(0)?, @@ -402,7 +402,8 @@ pub(crate) fn create_thread_submission( } if filesystem.schedule_thread_prune { super::posts::persist_thread_prune_intent_in_tx(conn, board_id) - .context("Persist required board prune intent failed")?; + .context("Persist required board prune intent failed") + .map(|_operation_summary| ())?; } Ok(PostCreationOutcome::Created((thread_id, post_id, poll_id))) @@ -480,7 +481,12 @@ pub(crate) fn create_reply_submission( FROM threads WHERE id = ?1 AND board_id = ?2", params![post.thread_id, post.board_id], - |row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)), + |row| { + Ok(( + row.get::<_, i32>(0)? != 0_i32, + row.get::<_, i32>(1)? != 0_i32, + )) + }, ) .optional()?; let Some((locked, archived)) = flags else { @@ -756,7 +762,8 @@ pub fn archive_old_threads(conn: &rusqlite::Connection, board_id: i64, max: i64) let sql = format!("UPDATE threads SET archived = 1, locked = 1 WHERE id IN ({placeholders})"); conn.execute(&sql, rusqlite::params_from_iter(&ids)) - .context("Failed to bulk archive threads")?; + .context("Failed to bulk archive threads") + .map(|_affected_rows| ())?; Ok(count) })(); @@ -835,7 +842,8 @@ pub fn prune_old_threads( .join(", "); let sql = format!("DELETE FROM threads WHERE id IN ({placeholders})"); conn.execute(&sql, rusqlite::params_from_iter(&ids)) - .context("Failed to bulk delete pruned threads")?; + .context("Failed to bulk delete pruned threads") + .map(|_affected_rows| ())?; // Determine safe paths INSIDE the transaction so the check sees the // post-delete state before any concurrent writer can insert new references. @@ -912,7 +920,8 @@ pub fn prune_old_archived_threads( .join(", "); let sql = format!("DELETE FROM threads WHERE id IN ({placeholders})"); conn.execute(&sql, rusqlite::params_from_iter(&ids)) - .context("Failed to bulk delete archived threads")?; + .context("Failed to bulk delete archived threads") + .map(|_affected_rows| ())?; let safe = super::paths_safe_to_delete(conn, candidates)?; let pending_fs_op = super::build_delete_files_pending_op(&safe)?; @@ -1101,11 +1110,13 @@ mod tests { conn.execute( "UPDATE threads SET created_at = 100, bumped_at = 100 WHERE id = ?1", params![thread_id], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "UPDATE posts SET created_at = 100 WHERE thread_id = ?1", params![thread_id], - )?; + ) + .map(|_affected_rows| ())?; let thread = super::get_thread(&conn, thread_id)?.context("opening thread should exist")?; anyhow::ensure!( thread.last_post_at == 100 && thread.image_count == 0, @@ -1125,16 +1136,17 @@ mod tests { false, None, )?; - for (post_id, timestamp) in [(image_id, 200), (audio_id, 300), (text_id, 400)] { + for (post_id, timestamp) in [(image_id, 200_i32), (audio_id, 300_i32), (text_id, 400_i32)] { conn.execute( "UPDATE posts SET created_at = ?1 WHERE id = ?2", params![timestamp, post_id], - )?; + ) + .map(|_affected_rows| ())?; } - let thread = + let replied_thread = super::get_thread(&conn, thread_id)?.context("thread should exist after replies")?; anyhow::ensure!( - thread.last_post_at == 400 && thread.bumped_at == 100 && thread.image_count == 1, + replied_thread.last_post_at == 400 && replied_thread.bumped_at == 100 && replied_thread.image_count == 1, "sage text replies update last-post time while bump time and image counts remain correct" ); let active = super::get_threads_for_board(&conn, board_id, 20, 0)?; @@ -1147,7 +1159,8 @@ mod tests { conn.execute( "UPDATE threads SET archived = 1 WHERE id = ?1", params![thread_id], - )?; + ) + .map(|_affected_rows| ())?; let archived = super::get_archived_threads_for_board(&conn, board_id, 20, 0)?; anyhow::ensure!( archived.len() == 1, @@ -1219,7 +1232,8 @@ mod tests { SET file_path = 'b/file.webp', media_processing_state = ?1 WHERE id = ?2", params![crate::db::MEDIA_ORIGINAL_PRUNE_PENDING, post_id], - )?; + ) + .map(|_affected_rows| ())?; assert!(validate_deduplicated_paths(&conn, &["b/file.webp"]).is_err()); assert!(crate::db::find_file_by_hash(&conn, "hash")?.is_none()); @@ -1234,8 +1248,8 @@ mod tests { fn prune_old_threads_commits_even_when_no_files_are_safe() -> Result<()> { let conn = test_conn()?; let board_id = create_board(&conn, "prune", "Prune", "", false)?; - create_plain_thread(&conn, board_id, "old thread")?; - create_plain_thread(&conn, board_id, "new thread")?; + create_plain_thread(&conn, board_id, "old thread").map(|_created_id| ())?; + create_plain_thread(&conn, board_id, "new thread").map(|_created_id| ())?; let board = get_board_by_short(&conn, "prune")?.context("prune board should exist")?; assert_eq!( count_threads_for_board(&conn, board.id)?, @@ -1273,7 +1287,8 @@ mod tests { conn.execute( "UPDATE threads SET archived = 1 WHERE id IN (?1, ?2)", params![first, second], - )?; + ) + .map(|_affected_rows| ())?; let deleted = prune_old_archived_threads(&conn, board_id, 1)?; assert!( @@ -1335,7 +1350,7 @@ mod tests { deletion_token: "token".to_owned(), is_op: false, }; - create_reply_with_thread_update(&conn, &reply, "", false, None)?; + create_reply_with_thread_update(&conn, &reply, "", false, None).map(|_created_id| ())?; let deleted = delete_thread(&conn, thread_id)?; assert!( @@ -1391,7 +1406,8 @@ mod tests { conn.execute( "UPDATE threads SET locked = 1 WHERE id = ?1", params![thread_id], - )?; + ) + .map(|_affected_rows| ())?; let reply = plain_reply(board_id, thread_id); let pending_op = pending_upload_op("locked-reply-upload"); @@ -1438,7 +1454,8 @@ mod tests { conn.execute( "UPDATE threads SET archived = 1 WHERE id = ?1", params![thread_id], - )?; + ) + .map(|_affected_rows| ())?; let reply = plain_reply(board_id, thread_id); let pending_op = pending_upload_op("archived-reply-upload"); @@ -1527,7 +1544,7 @@ mod tests { deletion_token: "token".to_owned(), is_op: false, }; - create_reply_with_thread_update(&conn, &reply, "", false, None)?; + create_reply_with_thread_update(&conn, &reply, "", false, None).map(|_created_id| ())?; let deleted = delete_thread(&conn, thread_id)?; assert!( @@ -1555,28 +1572,30 @@ mod tests { fn paged_thread_aggregates_match_the_unbounded_baseline() -> Result<()> { let conn = test_conn()?; let board = create_board(&conn, "page", "Page", "", false)?; - for number in 0..35 { + for number in 0_i32..35_i32 { let thread = create_plain_thread(&conn, board, &format!("thread {number}"))?; conn.execute( "UPDATE threads SET bumped_at=?1, sticky=?2, archived=?3 WHERE id=?4", params![ - 1_700_000_000 + number / 7, - i64::from(number % 11 == 0), - i64::from(number % 5 == 0), + 1_700_000_000_i32 + number / 7_i32, + i64::from(number % 11_i32 == 0_i32), + i64::from(number % 5_i32 == 0_i32), thread ], - )?; - for reply in 0..number % 7 { + ) + .map(|_affected_rows| ())?; + for reply in 0_i32..number % 7_i32 { conn.execute("INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token,file_path,created_at) VALUES(?1,?2,'reply','reply','delete',?3,?4)", - params![thread,board,(reply%2==0).then(|| format!("page/file-{number}-{reply}")),1_700_000_100+reply])?; + params![thread,board,(reply%2_i32==0_i32).then(|| format!("page/file-{number}-{reply}")),1_700_000_100_i32+reply]).map(|_affected_rows| ())?; } } // A missing OP must be excluded before pagination, matching the old JOIN. conn.execute( "INSERT INTO threads(board_id,bumped_at,sticky) VALUES(?1,1900000000,1)", [board], - )?; + ) + .map(|_affected_rows| ())?; for archived in [false, true] { let order = if archived { "t.bumped_at DESC" @@ -1620,7 +1639,8 @@ mod tests { let pool = crate::db::init_test_pool()?; let conn = pool.get()?; let board = create_board(&conn, "bump", "Bump", "", false)?; - conn.execute("UPDATE boards SET bump_limit=1 WHERE id=?1", [board])?; + conn.execute("UPDATE boards SET bump_limit=1 WHERE id=?1", [board]) + .map(|_affected_rows| ())?; let thread = create_plain_thread(&conn, board, "target")?; let op = crate::db::get_posts_for_thread(&conn, thread)? .into_iter() @@ -1652,35 +1672,38 @@ mod tests { drop(conn); let barrier = std::sync::Barrier::new(2); std::thread::scope(|scope| -> Result<()> { - let handles:Vec<_>=(0..2).map(|number| { + let handles:Vec<_>=(0_i32..2_i32).map(|number| { let pool=&pool; let post=&post; let barrier=&barrier; scope.spawn(move || -> Result<()> { - barrier.wait(); - let conn=pool.get()?; - super::create_reply_submission(&conn,post,&format!("bump-{number}"),true, - PostFilesystemCommit::new_with_validation(None,&[],false,|conn:&Connection| { + let _barrier_state = barrier.wait(); + let worker_conn=pool.get()?; + super::create_reply_submission(&worker_conn,post,&format!("bump-{number}"),true, + PostFilesystemCommit::new_with_validation(None,&[],false,|validation_conn:&Connection| { // Give the second serialized reply a distinguishable previous // timestamp; no sleep or wall-clock assumption is required. - conn.execute("UPDATE threads SET bumped_at=42 WHERE id=?1 AND reply_count=1",[thread])?; + validation_conn.execute("UPDATE threads SET bumped_at=42 WHERE id=?1 AND reply_count=1",[thread]).map(|_affected_rows| ())?; Ok(()) - }))?; + })).map(|_operation_summary| ())?; Ok(()) }) }).collect(); for handle in handles { - handle - .join() - .map_err(|_| anyhow::anyhow!("reply worker panicked"))??; + handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "reply worker panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })??; } Ok(()) })?; - let conn = pool.get()?; - let state = crate::db::get_thread(&conn, thread)?.context("thread")?; + let recovered_conn = pool.get()?; + let state = crate::db::get_thread(&recovered_conn, thread)?.context("thread")?; anyhow::ensure!( state.reply_count == 2 && state.bumped_at == 42, "concurrent replies exceeded the bump limit" ); - crate::db::verify_database_schema(&conn)?; + crate::db::verify_database_schema(&recovered_conn)?; Ok(()) } } diff --git a/src/db/user_thread_prefs.rs b/src/db/user_thread_prefs.rs index c093002c..534eaedc 100644 --- a/src/db/user_thread_prefs.rs +++ b/src/db/user_thread_prefs.rs @@ -17,7 +17,8 @@ fn cleanup_if_default(conn: &rusqlite::Connection, user_hash: &str, thread_id: i "DELETE FROM user_thread_preferences WHERE user_hash = ?1 AND thread_id = ?2 AND pinned = 0 AND hidden = 0", params![user_hash, thread_id], - )?; + ) + .map(|_affected_rows| ())?; Ok(()) } @@ -38,7 +39,8 @@ pub fn set_thread_hidden( hidden = excluded.hidden, updated_at = unixepoch()", params![user_hash, thread_id, i32::from(hidden)], - )?; + ) + .map(|_affected_rows| ())?; cleanup_if_default(conn, user_hash, thread_id)?; Ok(()) } @@ -60,7 +62,8 @@ pub fn set_thread_pinned( pinned = excluded.pinned, updated_at = unixepoch()", params![user_hash, thread_id, i32::from(pinned)], - )?; + ) + .map(|_affected_rows| ())?; cleanup_if_default(conn, user_hash, thread_id)?; Ok(()) } @@ -81,8 +84,8 @@ pub fn get_thread_preference( params![user_hash, thread_id], |row| { Ok(UserThreadPreference { - pinned: row.get::<_, i32>(0)? != 0, - hidden: row.get::<_, i32>(1)? != 0, + pinned: row.get::<_, i32>(0)? != 0_i32, + hidden: row.get::<_, i32>(1)? != 0_i32, }) }, ) @@ -112,8 +115,8 @@ pub fn get_preferences_for_board( Ok(( row.get::<_, i64>(0)?, UserThreadPreference { - pinned: row.get::<_, i32>(1)? != 0, - hidden: row.get::<_, i32>(2)? != 0, + pinned: row.get::<_, i32>(1)? != 0_i32, + hidden: row.get::<_, i32>(2)? != 0_i32, }, )) })?; @@ -121,7 +124,7 @@ pub fn get_preferences_for_board( let mut prefs = HashMap::new(); for row in rows { let (thread_id, pref) = row?; - prefs.insert(thread_id, pref); + let _previous_value = prefs.insert(thread_id, pref); } Ok(prefs) } @@ -181,7 +184,7 @@ mod tests { let pinned_barrier = Arc::clone(&barrier); let pinned = scope.spawn(move || -> Result<()> { let conn = pinned_pool.get()?; - pinned_barrier.wait(); + let _barrier_state = pinned_barrier.wait(); set_thread_pinned(&conn, "viewer", thread_id, true) }); @@ -189,16 +192,22 @@ mod tests { let hidden_barrier = Arc::clone(&barrier); let hidden = scope.spawn(move || -> Result<()> { let conn = hidden_pool.get()?; - hidden_barrier.wait(); + let _barrier_state = hidden_barrier.wait(); set_thread_hidden(&conn, "viewer", thread_id, true) }); - pinned - .join() - .map_err(|_| anyhow::anyhow!("pinned preference thread panicked"))??; - hidden - .join() - .map_err(|_| anyhow::anyhow!("hidden preference thread panicked"))??; + pinned.join().map_err(|panic_payload| { + anyhow::anyhow!( + "pinned preference thread panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })??; + hidden.join().map_err(|panic_payload| { + anyhow::anyhow!( + "hidden preference thread panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })??; Result::<()>::Ok(()) })?; diff --git a/src/db/workload.rs b/src/db/workload.rs index 4a9a5933..a6522c0f 100644 --- a/src/db/workload.rs +++ b/src/db/workload.rs @@ -158,8 +158,17 @@ fn pool(path: &Path, size: u32, synchronous: &'static str) -> Result Result<()> { - let thread = i64::from(operation % 800 + 1); - let board = (thread - 1) % 8 + 1; + let thread = i64::from( + (operation % 800) + .checked_add(1) + .context("workload thread index overflow")?, + ); + let board = thread + .checked_sub(1) + .context("invalid workload thread index")? + .rem_euclid(8) + .checked_add(1) + .context("workload board index overflow")?; match operation % 7 { 0 => drop(super::get_threads_for_board(conn, board, 10, 0)?), 1 => drop(super::get_threads_for_board(conn, board, 100, 0)?), @@ -171,7 +180,10 @@ fn read(conn: &Connection, operation: u32) -> Result<()> { conn, board, thread, - thread * 50 - 5, + thread + .checked_mul(50) + .and_then(|value| value.checked_sub(5)) + .context("workload post ID overflow")?, 100, )?), 4 => drop(super::search_posts(conn, board, "needle", 20, 0)?), @@ -180,7 +192,13 @@ fn read(conn: &Connection, operation: u32) -> Result<()> { drop(super::get_poll_for_thread(conn, thread, "fixture-voter")?); drop(super::find_file_by_hash( conn, - &format!("{:064x}", operation * 4 + 4), + &format!( + "{:064x}", + operation + .checked_mul(4) + .and_then(|value| value.checked_add(4)) + .context("workload hash index overflow")? + ), )?); } } @@ -189,18 +207,33 @@ fn read(conn: &Connection, operation: u32) -> Result<()> { /// Run a short post transaction with FTS, counters and submission-token work. fn write(conn: &Connection, worker: u32, operation: u32) -> Result<()> { - let thread = i64::from((operation * WORKERS + worker) % 799 + 1); - let board = (thread - 1) % 8 + 1; + let index = operation + .checked_mul(WORKERS) + .and_then(|value| value.checked_add(worker)) + .context("workload operation index overflow")?; + let thread = i64::from( + (index % 799) + .checked_add(1) + .context("workload thread index overflow")?, + ); + let board = thread + .checked_sub(1) + .context("invalid workload thread index")? + .rem_euclid(8) + .checked_add(1) + .context("workload board index overflow")?; conn.execute( "INSERT INTO posts(thread_id,board_id,body,body_html,ip_hash,deletion_token) VALUES(?1,?2,'workload reply','workload reply',?3,'workload-delete')", params![thread, board, format!("workload-{worker}")], - )?; + ) + .map(|_affected_rows| ())?; let post_id = conn.last_insert_rowid(); conn.execute( "UPDATE threads SET reply_count=reply_count+1,bumped_at=unixepoch() WHERE id=?1", [thread], - )?; + ) + .map(|_affected_rows| ())?; super::record_post_submission( conn, &format!("workload-{worker}-{operation}"), @@ -211,7 +244,7 @@ fn write(conn: &Connection, worker: u32, operation: u32) -> Result<()> { false, )?; if operation.is_multiple_of(17) { - super::enqueue_job(conn, "spam_check", "{}")?; + super::enqueue_job(conn, "spam_check", "{}").map(|_created_id| ())?; if let Some((job, _)) = super::claim_next_job(conn)? { super::complete_job(conn, job)?; } @@ -221,14 +254,16 @@ fn write(conn: &Connection, worker: u32, operation: u32) -> Result<()> { "INSERT OR IGNORE INTO poll_votes(poll_id,option_id,ip_hash) SELECT poll_id,id,?1 FROM poll_options WHERE position=0 LIMIT 1", [format!("workload-{worker}-{operation}")], - )?; + ) + .map(|_affected_rows| ())?; } if operation.is_multiple_of(41) { conn.execute( "DELETE FROM background_jobs WHERE id IN (SELECT id FROM background_jobs WHERE status='done' LIMIT 4)", [], - )?; + ) + .map(|_affected_rows| ())?; } Ok(()) } @@ -242,7 +277,15 @@ fn sample(pool: &super::DbPool, worker: u32, operation: u32, write_percent: u32) let mut write_wait_us = 0; let mut transaction_us = 0; let result = acquired.map_err(anyhow::Error::from).and_then(|conn| { - if (operation * 37 + worker * 13) % 100 >= write_percent { + let mix_index = operation + .checked_mul(37) + .and_then(|value| { + worker + .checked_mul(13) + .and_then(|worker_offset| value.checked_add(worker_offset)) + }) + .context("workload mix index overflow")?; + if mix_index % 100 >= write_percent { return read(&conn, operation); } let wait = Instant::now(); @@ -280,8 +323,15 @@ fn latencies(values: impl Iterator) -> Latencies { let mut values: Vec<_> = values.collect(); values.sort_unstable(); let percentile = |percent: usize| { - values - .get((values.len() * percent).div_ceil(100).saturating_sub(1)) + u128::try_from(values.len()) + .ok() + .and_then(|length| { + u128::try_from(percent) + .ok() + .and_then(|percent| length.checked_mul(percent)) + }) + .and_then(|rank| usize::try_from(rank.div_ceil(100).saturating_sub(1)).ok()) + .and_then(|index| values.get(index)) .copied() .unwrap_or(0) }; @@ -327,7 +377,8 @@ fn verify_stress_state(conn: &Connection) -> Result<()> { conn.execute( "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", [], - )?; + ) + .map(|_affected_rows| ())?; super::verify_database_schema(conn)?; let incorrect_counts: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM threads t WHERE t.reply_count != @@ -354,7 +405,7 @@ fn measure(path: &Path, size: u32, sync: &'static str, mix: (&str, u32)) -> Resu let pool = &pool; let barrier = Arc::clone(&barrier); scope.spawn(move || { - barrier.wait(); + let _barrier_state = barrier.wait(); (0..OPERATIONS) .map(|operation| sample(pool, worker, operation, mix.1)) .collect::>() @@ -363,11 +414,12 @@ fn measure(path: &Path, size: u32, sync: &'static str, mix: (&str, u32)) -> Resu .collect(); let mut samples = Vec::new(); for handle in handles { - samples.extend( - handle - .join() - .map_err(|_| anyhow::anyhow!("workload worker panicked"))?, - ); + samples.extend(handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "workload worker panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })?); } Ok(samples) })?; @@ -389,7 +441,12 @@ fn measure(path: &Path, size: u32, sync: &'static str, mix: (&str, u32)) -> Resu synchronous: sync.to_owned(), workload: mix.0.to_owned(), operations: WORKERS * OPERATIONS, - operations_per_second: f64::from(WORKERS * OPERATIONS - failures) / elapsed.as_secs_f64(), + operations_per_second: f64::from( + WORKERS + .checked_mul(OPERATIONS) + .and_then(|count| count.checked_sub(failures)) + .context("workload successful operation count overflow")?, + ) / elapsed.as_secs_f64(), latency: latencies(samples.iter().map(|s| s.elapsed_us)), read_latency: latencies( samples @@ -531,13 +588,13 @@ fn benchmark() -> Result<()> { let path = directory .path() .join(format!("{sync}-{size}-{}.sqlite3", mix.0)); - std::fs::copy(&seed_path, &path)?; + std::fs::copy(&seed_path, &path).map(|_bytes_copied| ())?; measurements.push(measure(&path, size, sync, mix)?); std::fs::write( &output, serde_json::to_vec_pretty(&serde_json::json!({ "sqlite_version":rusqlite::version(),"workers":WORKERS, - "fixture":{"boards":8,"threads":800,"posts":40000}, + "fixture":{"boards":8_i32,"threads":800_i32,"posts":40_000_i32}, "query_plans":query_plans,"measurements":measurements, "limitations":"SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss" }))?, @@ -549,7 +606,7 @@ fn benchmark() -> Result<()> { output, serde_json::to_vec_pretty(&serde_json::json!({ "sqlite_version": rusqlite::version(), "workers":WORKERS, - "fixture":{"boards":8,"threads":800,"posts":40000}, + "fixture":{"boards":8_i32,"threads":800_i32,"posts":40_000_i32}, "query_plans":query_plans, "measurements":measurements, "limitations":"SQL/database workload; media CPU and HTTP excluded; RSS sampled at case end; ps optional; process-kill is not power loss" }))?, @@ -608,11 +665,13 @@ fn crash_writer_child() -> Result<()> { "INSERT INTO posts(thread_id,board_id,body,body_html,deletion_token) VALUES(1,1,'crash reply','crash reply','delete')", [], - )?; + ) + .map(|_affected_rows| ())?; conn.execute( "UPDATE threads SET reply_count=reply_count+1 WHERE id=1", [], - )?; + ) + .map(|_affected_rows| ())?; if number == 100 { std::fs::write(path.with_extension("ready"), b"open transaction")?; // Give the parent a deterministic open transaction to interrupt. @@ -652,7 +711,7 @@ fn abrupt_writer_termination_recovers_wal_fts_jobs_and_fs_intents() -> Result<() std::thread::sleep(std::time::Duration::from_millis(10)); } child.0.kill()?; - child.0.wait()?; + child.0.wait().map(|_exit_status| ())?; let pool = pool(&path, 2, synchronous)?; let conn = pool.get()?; super::schema::install_or_migrate_schema(&conn)?; @@ -660,7 +719,8 @@ fn abrupt_writer_termination_recovers_wal_fts_jobs_and_fs_intents() -> Result<() conn.execute( "INSERT INTO posts_fts(posts_fts,rank) VALUES('integrity-check',1)", [], - )?; + ) + .map(|_affected_rows| ())?; let (replies, count): (i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM posts WHERE is_op=0),reply_count FROM threads WHERE id=1", [], @@ -674,7 +734,7 @@ fn abrupt_writer_termination_recovers_wal_fts_jobs_and_fs_intents() -> Result<() conn.is_autocommit(), "restart inherited an active transaction" ); - super::recover_interrupted_background_jobs(&conn)?; + super::recover_interrupted_background_jobs(&conn).map(|_operation_summary| ())?; let status: String = conn.query_row("SELECT status FROM background_jobs WHERE id=1", [], |r| { r.get(0) @@ -690,13 +750,13 @@ fn abrupt_writer_termination_recovers_wal_fts_jobs_and_fs_intents() -> Result<() !uploads.join("crash/src/remove.txt").exists(), "durable filesystem intent did not replay" ); - let conn = pool.get()?; + let recovered_conn = pool.get()?; ensure!( - super::list_pending_fs_ops(&conn)?.is_empty(), + super::list_pending_fs_ops(&recovered_conn)?.is_empty(), "filesystem intent was not completed" ); - conn.execute_batch("BEGIN IMMEDIATE; ROLLBACK")?; - super::verify_database_schema(&conn)?; + recovered_conn.execute_batch("BEGIN IMMEDIATE; ROLLBACK")?; + super::verify_database_schema(&recovered_conn)?; } Ok(()) } @@ -706,7 +766,7 @@ fn timed_query(conn: &Connection, sql: &str) -> Result<(Latencies, Vec)> { let mut timings = Vec::new(); let mut ids = Vec::new(); let mut statement = conn.prepare_cached(sql)?; - for _ in 0..25 { + for _ in 0_i32..25_i32 { let started = Instant::now(); ids = statement .query_map([], |r| r.get::<_, i64>(0))? @@ -801,10 +861,10 @@ fn query_profile() -> Result<()> { conn.execute_batch("CREATE INDEX idx_posts_thread_live ON posts(thread_id,id); CREATE INDEX idx_posts_board_ip_created ON posts(board_id,ip_hash,created_at DESC);")?; let size_after = super::get_db_size_bytes(&conn)?; - for ((name, sql), ((before_time, _), before_plan)) in queries.into_iter().zip(before) { - let (after_time, _) = timed_query(&conn, sql)?; + for ((name, sql), ((baseline_time, _), before_plan)) in queries.into_iter().zip(before) { + let (indexed_time, _) = timed_query(&conn, sql)?; evidence.push( - serde_json::json!({"query":name,"before":before_time,"after":after_time, + serde_json::json!({"query":name,"before":baseline_time,"after":indexed_time, "before_plan":before_plan,"after_plan":explain(&conn,sql)?}), ); } @@ -826,7 +886,7 @@ fn profile_search(conn: &Connection) -> Result> { WHERE p.board_id=1 AND posts_fts MATCH '\"{term}\"*' ORDER BY p.created_at DESC,p.id DESC LIMIT 20"); let (before_time, ids) = timed_query(conn, &before)?; let mut times = Vec::new(); - for _ in 0..25 { + for _ in 0_i32..25_i32 { let started = Instant::now(); let posts = super::search_posts(conn, 1, term, 20, 0)?; times.push(started.elapsed().as_micros()); @@ -846,7 +906,11 @@ fn profile_search(conn: &Connection) -> Result> { /// Measure representative write cost before and after candidate indexes. fn profile_writes(conn: &Connection, first: u32) -> Result { let mut timings = Vec::new(); - for operation in first..first + 100 { + for operation in first + ..first + .checked_add(100) + .context("workload profile range overflow")? + { let started = Instant::now(); conn.execute_batch("BEGIN IMMEDIATE")?; write(conn, 0, operation)?; diff --git a/src/detect.rs b/src/detect.rs index d10a8f46..65ca25b1 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -83,7 +83,7 @@ fn probe_tool(program: &str) -> bool { /// Probes an external tool with explicit arguments and a bounded timeout. fn probe_tool_with_args(program: &str, args: &[&str]) -> bool { let mut command = Command::new(program); - command + let _configured = command .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -263,7 +263,7 @@ pub fn tor_stream_token_identity( struct TokenGuard(SocketAddr); impl Drop for TokenGuard { fn drop(&mut self) { - TOR_STREAM_TOKENS.remove(&self.0); + let _previous_value = TOR_STREAM_TOKENS.remove(&self.0); } } @@ -300,7 +300,7 @@ pub fn detect_tor( loop { tracing::info!( target: "rustchan::detect", - attempt = attempt + 1, + attempt = attempt.saturating_add(1), "Starting Tor" ); let run_start = Instant::now(); @@ -375,12 +375,12 @@ async fn run_arti( let cache_dir = crate::config::runtime_tor_cache_dir(); let state_dir = crate::config::runtime_tor_state_dir(); - let key_dir = crate::config::runtime_tor_hidden_service_keys_dir(); + let service_key_dir = crate::config::runtime_tor_hidden_service_keys_dir(); tracing::info!( target: "rustchan::detect", cache_dir = %cache_dir.display(), state_dir = %state_dir.display(), - key_dir = %key_dir.display(), + key_dir = %service_key_dir.display(), "Tor: bootstrapping — first run downloads ~2 MB of directory data" ); @@ -403,11 +403,11 @@ async fn run_arti( tracing::info!(target: "rustchan::detect", "Tor: connected to the Tor network"); // Configurable nicknames distinguish instances that share Arti state. - let key_dir = crate::config::runtime_tor_hidden_service_keys_dir(); + let onion_key_dir = crate::config::runtime_tor_hidden_service_keys_dir(); tracing::info!( target: "rustchan::detect", nickname = %crate::config::CONFIG.tor_service_nickname, - key_dir = %key_dir.display(), + key_dir = %onion_key_dir.display(), "Tor: launching onion service" ); let svc_config = OnionServiceConfigBuilder::default() @@ -529,7 +529,7 @@ fn spawn_tor_stream_proxy( permit: tokio::sync::OwnedSemaphorePermit, local_addr: Arc, ) { - tokio::spawn(async move { + drop(tokio::spawn(async move { let result = proxy_tor_stream(stream_req, &local_addr).await; drop(permit); if let Err(e) = result { @@ -550,7 +550,7 @@ fn spawn_tor_stream_proxy( ); } } - }); + })); } // Connection proxy @@ -580,10 +580,12 @@ async fn proxy_tor_stream( Arc::from(format!("tor:{}", hex::encode(bytes)).as_str()) }; // _guard removes the map entry when this task ends (connection closed or error). - TOR_STREAM_TOKENS.insert(local_peer, Arc::clone(&token)); + let _previous_value = TOR_STREAM_TOKENS.insert(local_peer, Arc::clone(&token)); let _guard = TokenGuard(local_peer); - tokio::io::copy_bidirectional(&mut tor_stream, &mut local).await?; + tokio::io::copy_bidirectional(&mut tor_stream, &mut local) + .await + .map(|_completed_value| ())?; Ok(()) } @@ -593,7 +595,7 @@ async fn proxy_tor_stream( /// [`HsId`] does not implement `std::fmt::Display` in arti-client. /// Encoded manually using `HsId: AsRef<[u8; 32]>`. /// -/// Format: `base32( pubkey || sha3_256(".onion checksum" || pubkey || version)[..2] || version )` +/// Format: `base32( pubkey || sha3_256(".onion checksum" || pubkey || version)[..2] || version )`. fn hsid_to_onion_address(hsid: HsId) -> String { use sha3::{Digest as _, Sha3_256}; diff --git a/src/error.rs b/src/error.rs index 34f4c99c..e4eb0156 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,11 +8,11 @@ use tracing::error; #[derive(Debug, Error)] /// Error returned by application operations and HTTP handlers. pub enum AppError { - /// 404 — board or thread not found + /// 404 — board or thread not found. #[error("Not found: {0}")] NotFound(String), - /// 400 — bad input from user + /// 400 — bad input from user. #[error("Bad request: {0}")] BadRequest(String), @@ -30,19 +30,19 @@ pub enum AppError { csrf_token: String, }, - /// 413 — upload body too large + /// 413 — upload body too large. #[error("Upload too large: {0}")] UploadTooLarge(String), - /// 415 — MIME type not accepted + /// 415 — MIME type not accepted. #[error("Invalid media type: {0}")] InvalidMediaType(String), - /// 409 — resource already exists or snapshot already imported + /// 409 — resource already exists or snapshot already imported. #[error("Conflict: {0}")] Conflict(String), - /// 503 — database write contention; client should retry + /// 503 — database write contention; client should retry. #[error("Database busy — please retry")] DbBusy, @@ -202,7 +202,7 @@ impl IntoResponse for AppError { ) .into_response(); if retry_after { - response + let _previous_value = response .headers_mut() .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); } diff --git a/src/handlers/admin/accounts.rs b/src/handlers/admin/accounts.rs index 98868fd0..568c6cb5 100644 --- a/src/handlers/admin/accounts.rs +++ b/src/handlers/admin/accounts.rs @@ -43,13 +43,13 @@ pub(in crate::server) async fn update_account( } tokio::task::spawn_blocking(move || -> Result { let mut conn = state.db.get()?; - require_admin_session_sid(&conn, session.as_deref())?; + require_admin_session_sid(&conn, session.as_deref()).map(|_completed_value| ())?; if form.new_password != form.confirm_password { return Ok(super::admin_panel_error_redirect_anchor("Passwords did not match. No account changes saved.", "accounts").into_response()); } let result = db::manage_account(&mut conn, session.as_deref().unwrap_or_default(), action, form.username.trim(), &form.current_password, &form.new_password); match result { Ok(true) => Ok(super::admin_panel_redirect_anchor("Account saved. Password changes revoke all sessions for that account; sign in again if you changed your own password.", "accounts").into_response()), Ok(false) => { - super::auth::record_login_fail(&key); + let _failure_count = super::auth::record_login_fail(&key); Ok(super::admin_panel_error_redirect_anchor("Current password did not verify. No account changes saved.", "accounts").into_response()) } Err(error) => Ok(super::admin_panel_error_redirect_anchor(&format!("No account changes saved: {error}"), "accounts").into_response()), @@ -87,7 +87,7 @@ pub(in crate::server) async fn rotate_secret( let actor = db::get_admin_name_by_id(&conn, actor)?.ok_or_else(|| AppError::Forbidden("Administrator missing".into()))?; let user = db::get_admin_by_username(&conn, &actor)?.ok_or_else(|| AppError::Forbidden("Administrator missing".into()))?; if password.len() > 1024 || !crate::utils::crypto::verify_password(password, &user.password_hash)? { - super::auth::record_login_fail(&key); + let _failure_count = super::auth::record_login_fail(&key); return Ok(super::admin_panel_error_redirect_anchor("Current password did not verify. No secret changes saved.", "storage").into_response()); } let rotation = crate::config::admin::management::rotate_secret(); diff --git a/src/handlers/admin/auth.rs b/src/handlers/admin/auth.rs index 3d87ee85..a02c9a34 100644 --- a/src/handlers/admin/auth.rs +++ b/src/handlers/admin/auth.rs @@ -51,7 +51,7 @@ fn admin_login_fail_window_secs() -> u64 { /// Admin login CSRF scope used by this handler. const ADMIN_LOGIN_CSRF_SCOPE: &str = "admin-login"; -/// `ip_hash` → (`fail_count`, `window_start_secs`) +/// `ip_hash` → (`fail_count`, `window_start_secs`). static ADMIN_LOGIN_FAILS: LazyLock> = LazyLock::new(DashMap::new); static LOGIN_CLEANUP_SECS: AtomicU64 = AtomicU64::new(0); @@ -123,7 +123,7 @@ pub(super) fn record_login_fail(ip_key: &str) -> u32 { } fn clear_login_fails(ip_key: &str) { - ADMIN_LOGIN_FAILS.remove(ip_key); + drop(ADMIN_LOGIN_FAILS.remove(ip_key)); } /// Remove login-fail entries whose window has expired. @@ -184,7 +184,7 @@ async fn render_admin_login_response( } }) .await - .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??; + .map_err(|join_error| AppError::Internal(anyhow::anyhow!(join_error)))??; Ok(( jar, Html(templates::admin_login_page( @@ -346,12 +346,17 @@ pub(in crate::server) async fn admin_login( clear_login_fails(&ip_key); let session_id = new_session_id(); let bootstrap_session_id = session_id.clone(); - let expires_at = Utc::now().timestamp() + CONFIG.session_duration; + let expires_at = Utc::now() + .timestamp() + .checked_add(CONFIG.session_duration) + .ok_or_else(|| { + AppError::Internal(anyhow::anyhow!("administrator session expiry overflows")) + })?; let sid_clone = session_id.clone(); tokio::task::spawn_blocking({ - let pool = state.db.clone(); + let session_pool = state.db.clone(); move || -> Result<()> { - let conn = pool.get()?; + let conn = session_pool.get()?; db::create_session(&conn, &sid_clone, admin_id, expires_at)?; Ok(()) } @@ -433,7 +438,6 @@ mod tests { routing::post, Router, }; - use axum_extra::extract::cookie::{Cookie, CookieJar}; use tower::ServiceExt as _; const TEST_CSRF_COOKIE: &str = "csrf123"; @@ -481,7 +485,9 @@ mod tests { .db .get() .context("get database connection for test board")?; - db::create_board(&conn, "test", "Test", "", false).context("create test board")?; + db::create_board(&conn, "test", "Test", "", false) + .context("create test board") + .map(|_completed_value| ())?; Ok(()) } @@ -492,8 +498,12 @@ mod tests { .context("get database connection for test administrator")?; let password_hash = crate::utils::crypto::hash_password("hunter2") .context("hash test administrator password")?; - db::create_admin(&conn, "admin", &password_hash).context("create test administrator")?; - db::create_board(&conn, "test", "Test", "", false).context("create test board")?; + db::create_admin(&conn, "admin", &password_hash) + .context("create test administrator") + .map(|_completed_value| ())?; + db::create_board(&conn, "test", "Test", "", false) + .context("create test board") + .map(|_completed_value| ())?; Ok(()) } @@ -542,39 +552,42 @@ mod tests { fn locked_after_exceeding_fail_limit() { let key = login_ip_key("test-lock-unique-99887766"); // Clean up any residue from a previous run - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); let now = login_now_secs(); - ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit(), now)); + let _previous_failure_window = + ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit(), now)); assert!(is_login_locked(&key)); // Cleanup - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); } #[test] fn not_locked_below_fail_limit() { let key = login_ip_key("test-below-limit-11223344"); - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); let now = login_now_secs(); - ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit() - 1, now)); + let _previous_failure_window = + ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit() - 1, now)); assert!(!is_login_locked(&key)); - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); } #[test] fn expired_window_is_not_locked() { let key = login_ip_key("test-expired-window-55667788"); - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); // window_start far in the past, beyond admin_login_fail_window_secs() let old_ts = login_now_secs().saturating_sub(admin_login_fail_window_secs() + 60); - ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit() + 10, old_ts)); + let _previous_failure_window = + ADMIN_LOGIN_FAILS.insert(key.clone(), (admin_login_fail_limit() + 10, old_ts)); assert!(!is_login_locked(&key)); - ADMIN_LOGIN_FAILS.remove(&key); + drop(ADMIN_LOGIN_FAILS.remove(&key)); } #[tokio::test] @@ -583,8 +596,9 @@ mod tests { create_test_board(&state)?; let ip_key = login_ip_key("192.0.2.44"); - ADMIN_LOGIN_FAILS.remove(&ip_key); - ADMIN_LOGIN_FAILS.insert(ip_key.clone(), (admin_login_fail_limit(), login_now_secs())); + drop(ADMIN_LOGIN_FAILS.remove(&ip_key)); + let _previous_failure_window = + ADMIN_LOGIN_FAILS.insert(ip_key.clone(), (admin_login_fail_limit(), login_now_secs())); let router = Router::new() .route("/admin/login", post(admin_login)) @@ -594,18 +608,15 @@ mod tests { "username=admin&password=wrong&_csrf={}", signed_admin_csrf() ))?; - request - .extensions_mut() - .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( - [192, 0, 2, 44], - 41000, - )))); + let _previous_peer = request.extensions_mut().insert(axum::extract::ConnectInfo( + std::net::SocketAddr::from(([192, 0, 2, 44], 41000)), + )); let response = router .oneshot(request) .await .context("send locked-out admin login request")?; - ADMIN_LOGIN_FAILS.remove(&ip_key); + drop(ADMIN_LOGIN_FAILS.remove(&ip_key)); anyhow::ensure!( response.status() == StatusCode::OK, @@ -636,7 +647,7 @@ mod tests { "username=admin&password=hunter2&_csrf={}", signed_admin_csrf() ))?; - request + let _previous_transport = request .extensions_mut() .insert(crate::middleware::RequestTransport { direct_https: true }); let response = router diff --git a/src/handlers/admin/backup.rs b/src/handlers/admin/backup.rs index 02569d2e..5589db21 100644 --- a/src/handlers/admin/backup.rs +++ b/src/handlers/admin/backup.rs @@ -143,7 +143,7 @@ pub(in crate::server) async fn admin_backup( let pool = state.db.clone(); move || -> Result<(PathBuf, String, u64)> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let uploads_base = Path::new(&upload_dir); progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); @@ -230,8 +230,9 @@ pub(in crate::server) async fn admin_backup( .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {e}")))?; drop(db_src); drop(std::fs::remove_file(&temp_db)); - progress.files_done.fetch_add(1, Ordering::Relaxed); - progress.bytes_done.fetch_add(copied, Ordering::Relaxed); + let _files_before_snapshot = progress.files_done.fetch_add(1, Ordering::Relaxed); + let _bytes_before_snapshot = + progress.bytes_done.fetch_add(copied, Ordering::Relaxed); log_backup_progress(&progress); // Upload files (streamed file-by-file via io::copy) @@ -316,10 +317,10 @@ pub(in crate::server) async fn admin_backup( // Schedule temp-file cleanup after a generous window so even slow clients finish. let cleanup_path = tmp_path; - tokio::spawn(async move { + drop(tokio::spawn(async move { tokio::time::sleep(Duration::from_mins(10)).await; drop(tokio::fs::remove_file(cleanup_path).await); - }); + })); let disposition = format!("attachment; filename=\"{filename}\""); Ok(( @@ -350,11 +351,11 @@ fn count_files_in_dir(dir: &Path) -> u64 { if metadata.file_type().is_symlink() { acc } else if metadata.file_type().is_dir() { - acc + count_files_in_dir(&p) + acc.saturating_add(count_files_in_dir(&p)) } else if metadata.file_type().is_file() && crate::utils::fs_security::assert_regular_file_no_symlink(&p).is_ok() { - acc + 1 + acc.saturating_add(1) } else { acc } @@ -425,8 +426,9 @@ pub(super) fn add_dir_to_zip_with_prefix( let copied = std::io::copy(&mut src, zip).map_err(|e| { AppError::Internal(anyhow::anyhow!("copy {} to zip: {}", path.display(), e)) })?; - progress.files_done.fetch_add(1, Ordering::Relaxed); - progress.bytes_done.fetch_add(copied, Ordering::Relaxed); + let _files_before_archive_entry = progress.files_done.fetch_add(1, Ordering::Relaxed); + let _bytes_before_archive_entry = + progress.bytes_done.fetch_add(copied, Ordering::Relaxed); log_backup_progress(progress); } } @@ -519,7 +521,7 @@ fn unique_backup_filename(dir: &Path, base_name: &str) -> String { } } -/// rustchan-data/runtime/tmp/board-downloads/ +/// rustchan-data/runtime/tmp/board-downloads/. fn temp_board_download_dir() -> PathBuf { crate::config::runtime_temp_board_downloads_dir() } @@ -558,7 +560,7 @@ pub(in crate::server) async fn board_backup( let safe_board = safe_board.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; conn.query_row( "SELECT 1 FROM boards WHERE short_name = ?1", params![safe_board], @@ -668,13 +670,17 @@ mod tests { ); install_admin_session(&state)?; for path in ["", "relative", "%00"] { - let body = format!("_csrf={}&backup_directory={path}", admin_signed_csrf()); - let response = app + let invalid_update_body = + format!("_csrf={}&backup_directory={path}", admin_signed_csrf()); + let invalid_update_response = app .clone() - .oneshot(admin_form_post("/admin/backup/settings", body)?) + .oneshot(admin_form_post( + "/admin/backup/settings", + invalid_update_body, + )?) .await?; - ensure!(response.status() == StatusCode::BAD_REQUEST); - ensure!(response_body_string(response) + ensure!(invalid_update_response.status() == StatusCode::BAD_REQUEST); + ensure!(response_body_string(invalid_update_response) .await? .contains("backup_directory")); } @@ -703,14 +709,14 @@ mod tests { let temp = tempfile::tempdir()?; let data = temp.path().join("data"); let mut command = std::process::Command::new(std::env::current_exe()?); - command + let _base_environment = command .args(["--exact", name, "--nocapture"]) .env("RUSTCHAN_TEST_BACKUP_DATA", &data) .env("CHAN_TOR_SUPPORT", "0") .env_remove("CHAN_BACKUP_DIRECTORY"); let custom = temp.path().join("disk/custom-backups"); if source == "environment" { - command.env("CHAN_BACKUP_DIRECTORY", &custom); + let _custom_backup_environment = command.env("CHAN_BACKUP_DIRECTORY", &custom); } else if source == "settings" { std::fs::create_dir(&data)?; std::fs::write( @@ -776,9 +782,11 @@ mod tests { )?; let saved = root.join(&filename); ensure!(saved.is_dir()); - super::storage::verify_saved_backup(&saved, &[super::storage::BackupScope::FullSite])?; - let listed = super::list_backup_files(&full_backup_dir(), BackupListKind::Full); - ensure!(listed + super::storage::verify_saved_backup(&saved, &[super::storage::BackupScope::FullSite]) + .map(|_completed_value| ())?; + let updated_listing = + super::list_backup_files(&full_backup_dir(), BackupListKind::Full); + ensure!(updated_listing .iter() .any(|backup| backup.backup_ref == filename && backup.verified)); ensure!(latest_verified_full_backup_modified_time().is_some()); @@ -797,7 +805,8 @@ mod tests { "Storage test", "Storage test", "Storage test", - )?; + ) + .map(|_completed_value| ())?; } let removed = super::enforce_full_backup_retention(1)?; ensure!(!removed.is_empty()); @@ -843,7 +852,10 @@ mod tests { .write_all(body) .with_context(|| format!("write ZIP entry {name}"))?; } - writer.finish().context("finish ZIP archive")?; + writer + .finish() + .context("finish ZIP archive") + .map(|_completed_value| ())?; } cursor.set_position(0); zip::ZipArchive::new(cursor).context("parse ZIP archive") @@ -944,7 +956,7 @@ mod tests { &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create test admin session")?; Ok(()) @@ -993,7 +1005,8 @@ mod tests { { let conn = state.db.get().context("get database connection")?; crate::db::create_board(&conn, &board_short, "Board", "", false) - .context("create board")?; + .context("create board") + .map(|_completed_value| ())?; } let app = Router::new() .route("/admin/board/backup/{board}", get(super::board_backup)) @@ -1164,7 +1177,8 @@ mod tests { { let conn = state.db.get().context("get database connection")?; crate::db::create_board(&conn, &marker_board, "Maintenance Marker", "", false) - .context("create marker board")?; + .context("create marker board") + .map(|_completed_value| ())?; } std::fs::create_dir_all(full_backup_dir()).context("create full backup directory")?; @@ -1341,8 +1355,8 @@ mod tests { std::fs::write(&backup_path, b"not-a-zip").context("write invalid ZIP")?; let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("localhost")); - headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost")); + drop(headers.insert(header::HOST, HeaderValue::from_static("localhost"))); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost"))); let response = super::restore_saved_full_backup( axum::extract::State(state), @@ -1564,8 +1578,8 @@ mod tests { admin_cookie_jar(), { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("localhost")); - headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost")); + drop(headers.insert(header::HOST, HeaderValue::from_static("localhost"))); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost"))); headers }, crate::test_support::connect_info(), @@ -1811,7 +1825,8 @@ mod tests { .get() .context("get source database connection")?; crate::db::create_board(&source_conn, "tech", "Technology", "", false) - .context("create source board")?; + .context("create source board") + .map(|_completed_value| ())?; let mut manifest = build_board_backup_manifest(&source_conn, "tech")?; manifest.board.access_mode = "definitely_not_valid".to_owned(); @@ -1849,7 +1864,8 @@ mod tests { .get() .context("get source database connection")?; crate::db::create_board(&source_conn, "tech", "Technology", "", false) - .context("create source board")?; + .context("create source board") + .map(|_completed_value| ())?; let mut manifest = build_board_backup_manifest(&source_conn, "tech")?; manifest.board.access_mode = "view_password".to_owned(); manifest.board.access_password_hash.clear(); @@ -1894,7 +1910,8 @@ mod tests { "UPDATE boards SET allow_pdf = 1 WHERE id = ?1", params![board_id], ) - .context("enable PDF uploads")?; + .context("enable PDF uploads") + .map(|_completed_value| ())?; let manifest = build_board_backup_manifest(&source_conn, "tech")?; @@ -1915,7 +1932,8 @@ mod tests { "UPDATE boards SET allow_pdf = 1 WHERE id = ?1", params![board_id], ) - .context("enable PDF uploads")?; + .context("enable PDF uploads") + .map(|_completed_value| ())?; let manifest = build_board_backup_manifest(&source_conn, "tech")?; let target_pool = crate::db::init_test_pool().context("create target database pool")?; @@ -1935,7 +1953,8 @@ mod tests { "Test PDF setting restore", "Test PDF setting restore completed", ) - .context("restore board")?; + .context("restore board") + .map(|_completed_value| ())?; let restored = crate::db::get_board_by_short(&target_conn, "tech") .context("load restored board")? @@ -1969,7 +1988,8 @@ mod tests { true, None, ) - .context("create source reply")?; + .context("create source reply") + .map(|_completed_value| ())?; let mut manifest = build_board_backup_manifest(&source_conn, "tech")?; manifest .threads @@ -1994,7 +2014,8 @@ mod tests { "Test reply-count restore", "Test reply-count restore completed", ) - .context("restore board")?; + .context("restore board") + .map(|_completed_value| ())?; let restored_count = target_conn .query_row( @@ -2013,22 +2034,22 @@ mod tests { #[test] fn older_board_restore_manifests_default_pdf_uploads_off() -> TestResult<()> { let json = serde_json::json!({ - "version": 1, + "version": 1_i32, "board": { - "id": 1, + "id": 1_i32, "short_name": "tech", "name": "Technology", "description": "", "nsfw": false, - "max_threads": 100, - "max_archived_threads": 150, - "bump_limit": 300, + "max_threads": 100_i32, + "max_archived_threads": 150_i32, + "bump_limit": 300_i32, "allow_images": true, "allow_video": true, "allow_audio": true, "allow_any_files": false, "allow_tripcodes": true, - "edit_window_secs": 300, + "edit_window_secs": 300_i32, "allow_editing": true, "allow_self_delete": true, "allow_archive": true, @@ -2036,11 +2057,11 @@ mod tests { "allow_captcha": false, "show_poster_ids": false, "collapse_greentext": false, - "post_cooldown_secs": 0, + "post_cooldown_secs": 0_i32, "banner_mode": "inherit", "access_mode": "public", "access_password_hash": "", - "created_at": 1_700_000_000 + "created_at": 1_700_000_000_i32 }, "threads": [], "posts": [], @@ -2160,7 +2181,8 @@ mod tests { { let conn = pool.get().context("get database connection")?; crate::db::create_board(&conn, board_short, "Technology", "", false) - .context("create fixture board")?; + .context("create fixture board") + .map(|_completed_value| ())?; let board = crate::db::get_board_by_short(&conn, board_short) .context("load fixture board")? .context("fixture board not found")?; @@ -2195,7 +2217,8 @@ mod tests { None, None, ) - .context("create fixture thread")?; + .context("create fixture thread") + .map(|_completed_value| ())?; let db_path_str = db_path .to_str() @@ -2244,7 +2267,7 @@ mod tests { zip.start_file(format!("uploads/{board_short}/hello.txt"), options) .context("start upload ZIP entry")?; zip.write_all(b"hello").context("write upload ZIP entry")?; - zip.finish().context("finish backup ZIP")?; + drop(zip.finish().context("finish backup ZIP")?); } Ok(()) } @@ -2336,7 +2359,8 @@ mod tests { crate::handlers::admin::backup::storage::board_file_fixtures(), Some(b"sqlite".to_vec()), 4_102_444_800, - )?; + ) + .map(|_completed_value| ())?; std::thread::sleep(std::time::Duration::from_millis(20)); crate::handlers::admin::backup::storage::write_saved_backup_fixture( &newer_dir_mtime_dir, @@ -2344,7 +2368,8 @@ mod tests { crate::handlers::admin::backup::storage::board_file_fixtures(), Some(b"sqlite".to_vec()), 4_102_444_700, - )?; + ) + .map(|_completed_value| ())?; invalidate_backup_list_cache(&full_backup_dir(), BackupListKind::Full); let modified = latest_verified_full_backup_modified_time() @@ -2395,7 +2420,9 @@ mod tests { let upload_dir = tempfile::tempdir().context("create upload directory")?; let mut conn = pool.get().context("get database connection")?; - crate::db::create_board(&conn, "tech", "Technology", "", false).context("create board")?; + crate::db::create_board(&conn, "tech", "Technology", "", false) + .context("create board") + .map(|_completed_value| ())?; let tech_board = crate::db::get_board_by_short(&conn, "tech") .context("load board")? .context("tech board not found")?; @@ -2421,9 +2448,13 @@ mod tests { .context("create reply")?; let manifest = build_board_backup_manifest(&conn, "tech")?; - crate::db::delete_board(&conn, tech_board.id).context("delete board")?; + crate::db::delete_board(&conn, tech_board.id) + .context("delete board") + .map(|_completed_value| ())?; - crate::db::create_board(&conn, "b", "Random", "", false).context("create other board")?; + crate::db::create_board(&conn, "b", "Random", "", false) + .context("create other board") + .map(|_completed_value| ())?; let other_board = crate::db::get_board_by_short(&conn, "b") .context("load other board")? .context("other board not found")?; @@ -2452,7 +2483,8 @@ mod tests { "Test board restore", "Test board restore completed", ) - .context("restore board")?; + .context("restore board") + .map(|_completed_value| ())?; let restored_op = crate::db::get_post_on_board(&conn, "tech", op_post_id) .context("load restored OP")? @@ -2484,11 +2516,12 @@ mod tests { .get() .context("get source database connection")?; crate::db::create_board(&source_conn, "pad", "Padding", "", false) - .context("create padding board")?; + .context("create padding board") + .map(|_completed_value| ())?; let pad_board = crate::db::get_board_by_short(&source_conn, "pad") .context("load padding board")? .context("padding board not found")?; - for idx in 0..5 { + for idx in 0_i32..5_i32 { crate::db::create_thread_with_optional_poll( &source_conn, pad_board.id, @@ -2498,10 +2531,12 @@ mod tests { None, None, ) - .context("create padding thread")?; + .context("create padding thread") + .map(|_completed_value| ())?; } crate::db::create_board(&source_conn, "tech", "Technology", "", false) - .context("create source tech board")?; + .context("create source tech board") + .map(|_completed_value| ())?; let source_tech_board = crate::db::get_board_by_short(&source_conn, "tech") .context("load source tech board")? .context("source tech board not found")?; @@ -2537,7 +2572,8 @@ mod tests { .get() .context("get target database connection")?; crate::db::create_board(&target_conn, "b", "Random", "", false) - .context("create target board")?; + .context("create target board") + .map(|_completed_value| ())?; let target_b = crate::db::get_board_by_short(&target_conn, "b") .context("load target board")? .context("target board not found")?; @@ -2550,7 +2586,8 @@ mod tests { None, None, ) - .context("create target thread")?; + .context("create target thread") + .map(|_completed_value| ())?; let upload_dir = tempfile::tempdir().context("create upload directory")?; let upload_dir_str = upload_dir @@ -2565,7 +2602,8 @@ mod tests { "Test board restore high ids", "Test board restore high ids completed", ) - .context("restore board with high IDs")?; + .context("restore board with high IDs") + .map(|_completed_value| ())?; let restored_op = crate::db::get_post_on_board(&target_conn, "tech", source_op_id) .context("load restored OP")? @@ -2607,7 +2645,8 @@ mod tests { .get() .context("get source database connection")?; crate::db::create_board(&source_conn, "tech", "Technology", "", false) - .context("create source tech board")?; + .context("create source tech board") + .map(|_completed_value| ())?; let source_board = crate::db::get_board_by_short(&source_conn, "tech") .context("load source tech board")? .context("source tech board not found")?; @@ -2644,7 +2683,8 @@ mod tests { .get() .context("get target database connection")?; crate::db::create_board(&target_conn, "b", "Random", "", false) - .context("create target board")?; + .context("create target board") + .map(|_completed_value| ())?; let target_board = crate::db::get_board_by_short(&target_conn, "b") .context("load target board")? .context("target board not found")?; @@ -2681,7 +2721,8 @@ mod tests { "Test board restore remap", "Test board restore remap completed", ) - .context("restore board with ID collisions")?; + .context("restore board with ID collisions") + .map(|_completed_value| ())?; let restored_board_id: i64 = target_conn .query_row( diff --git a/src/handlers/admin/backup/archive.rs b/src/handlers/admin/backup/archive.rs index 95846cbf..e41a96bd 100644 --- a/src/handlers/admin/backup/archive.rs +++ b/src/handlers/admin/backup/archive.rs @@ -86,7 +86,8 @@ pub(super) fn extract_sqlite_db_from_full_backup_archive( .map_err(|error| AppError::Internal(anyhow::anyhow!("Copy {zip_path}: {error}"))) } +/// Finalize ZIP metadata and surface buffered-output flush failures to the caller. +fn finalize_archive(zip: zip::ZipWriter, destination: &Path) -> Result<()> { + let mut writer = zip.finish().map_err(|error| { + AppError::Internal(anyhow::anyhow!( + "Finalize {}: {error}", + destination.display() + )) + })?; + writer.flush().map_err(|error| { + AppError::Internal(anyhow::anyhow!("Flush {}: {error}", destination.display())) + }) +} + fn temporary_archive_path(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!("{prefix}_{}.zip", uuid::Uuid::new_v4().simple())) } @@ -256,7 +271,17 @@ pub(super) fn convert_transfer_zip_to_full_restore_archive { upload_file_count = upload_file_count.saturating_add(1); } - _ => {} + storage::BackupFileKind::Db + | storage::BackupFileKind::Settings + | storage::BackupFileKind::BoardJson + | storage::BackupFileKind::ThreadExport + | storage::BackupFileKind::PostExport + | storage::BackupFileKind::FileInventoryExport + | storage::BackupFileKind::Audio + | storage::BackupFileKind::TorKey + | storage::BackupFileKind::Maintenance + | storage::BackupFileKind::PendingFsOps + | storage::BackupFileKind::Log => {} } } } else if let Some(rel) = name.strip_prefix("site-assets/favicon/") { @@ -296,7 +321,7 @@ pub(super) fn convert_transfer_zip_to_full_restore_archive Result Result {} + storage::BackupFileKind::Db + | storage::BackupFileKind::Settings + | storage::BackupFileKind::BoardJson + | storage::BackupFileKind::ThreadExport + | storage::BackupFileKind::PostExport + | storage::BackupFileKind::FileInventoryExport + | storage::BackupFileKind::Audio + | storage::BackupFileKind::TorKey + | storage::BackupFileKind::Maintenance + | storage::BackupFileKind::PendingFsOps + | storage::BackupFileKind::Log => {} } } } @@ -522,9 +557,7 @@ fn create_full_restore_archive(verified: &storage::VerifiedBackup) -> Result>); + + impl Write for FlushFailure { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.write(bytes) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("injected final flush failure")) + } + } + + impl Seek for FlushFailure { + fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result { + self.0.seek(position) + } + } + + #[test] + fn archive_publication_reports_final_flush_failures() -> TestResult<()> { + let output = FlushFailure(std::io::Cursor::new(Vec::new())); + let zip = zip::ZipWriter::new(output); + let error = finalize_archive(zip, Path::new("test.zip")) + .err() + .context("final flush failure was hidden")?; + ensure!( + error.to_string().contains("injected final flush failure"), + "underlying flush error was lost" + ); + Ok(()) + } + use super::*; use anyhow::{ensure, Context as _, Result as TestResult}; @@ -692,7 +755,8 @@ mod tests { storage::board_file_fixtures(), Some(storage::database_snapshot_fixture()?), 1_715_010_000_i64, - )?; + ) + .map(|_completed_value| ())?; Ok((dir, root)) } @@ -705,7 +769,8 @@ mod tests { storage::board_file_fixtures(), None, 1_715_020_000_i64, - )?; + ) + .map(|_completed_value| ())?; Ok((dir, root)) } diff --git a/src/handlers/admin/backup/create.rs b/src/handlers/admin/backup/create.rs index e2824c17..109c39c2 100644 --- a/src/handlers/admin/backup/create.rs +++ b/src/handlers/admin/backup/create.rs @@ -30,12 +30,12 @@ pub(in crate::server) fn create_full_backup_to_server( storage_mode: BackupStorageMode, split_zip_part_size: u64, ) -> Result { - let conn = pool.get()?; + let auth_conn = pool.get()?; let automated = session_id.is_none(); if let Some(session_id) = session_id { - require_admin_session_sid(&conn, Some(session_id))?; + require_admin_session_sid(&auth_conn, Some(session_id)).map(|_completed_value| ())?; } - drop(conn); + drop(auth_conn); let uploads_base = Path::new(&CONFIG.upload_dir); let global_favicon_dir = crate::favicon::global_backup_source_dir(); let mut tor_hidden_service_keys_dir = if include_tor_hidden_service_keys { @@ -89,7 +89,7 @@ pub(in crate::server) fn create_full_backup_to_server( } else { 0 }; - let include_tor_hidden_service_keys = tor_hidden_service_keys_dir.is_some(); + let tor_hidden_service_keys_included = tor_hidden_service_keys_dir.is_some(); let file_count = count_files_in_dir(uploads_base) .saturating_add(favicon_file_count) .saturating_add(banner_file_count) @@ -108,17 +108,18 @@ pub(in crate::server) fn create_full_backup_to_server( .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Backup DB path non-UTF-8")))? .replace('\'', "''"); - let conn = pool.get()?; + let snapshot_conn = pool.get()?; if let Some(session_id) = session_id { - require_admin_session_sid(&conn, Some(session_id))?; + require_admin_session_sid(&snapshot_conn, Some(session_id)).map(|_completed_value| ())?; } - conn.execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) + snapshot_conn + .execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) .map_err(|error| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {error}")))?; - drop(conn); + drop(snapshot_conn); restrict_backup_file(&db_snapshot_path)?; // Export metadata from the same committed snapshot shipped in the backup; // file copying and hashing no longer occupy a live pooled connection. - let conn = rusqlite::Connection::open_with_flags( + let export_conn = rusqlite::Connection::open_with_flags( &db_snapshot_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, )?; @@ -138,7 +139,7 @@ pub(in crate::server) fn create_full_backup_to_server( db_snapshot_sha.clone(), ); - let boards = collect_backup_board_summaries(&conn)?; + let boards = collect_backup_board_summaries(&export_conn)?; let settings_path = crate::config::data_dir().join("settings.toml"); if settings_path.is_file() { let destination = config_dir.join("settings.toml"); @@ -162,7 +163,7 @@ pub(in crate::server) fn create_full_backup_to_server( for board in &boards { validate_board_short_name(&board.short_name)?; - let board_manifest = build_board_backup_manifest(&conn, &board.short_name)?; + let board_manifest = build_board_backup_manifest(&export_conn, &board.short_name)?; write_board_exports(&root_dir, &board_manifest, &mut files)?; } @@ -252,7 +253,7 @@ pub(in crate::server) fn create_full_backup_to_server( settings: settings_path.is_file(), uploads: true, thumbnails: true, - tor_keys: include_tor_hidden_service_keys, + tor_keys: tor_hidden_service_keys_included, board_exports: true, file_inventory: true, }, @@ -260,8 +261,8 @@ pub(in crate::server) fn create_full_backup_to_server( path: "db/rustchan.sqlite3".to_owned(), size: db_snapshot_size, sha256: db_snapshot_sha, - integrity_check: Some(snapshot_db_health_output(&conn, "integrity_check")), - foreign_key_check: Some(snapshot_db_health_output(&conn, "foreign_key_check")), + integrity_check: Some(snapshot_db_health_output(&export_conn, "integrity_check")), + foreign_key_check: Some(snapshot_db_health_output(&export_conn, "foreign_key_check")), }), files, parts: Vec::new(), @@ -270,7 +271,7 @@ pub(in crate::server) fn create_full_backup_to_server( if storage_mode == BackupStorageMode::SplitZip { materialize_split_zip_parts(&root_dir, &mut manifest, split_zip_part_size)?; } - drop(conn); + drop(export_conn); let backup_ref = finalize_saved_backup(&root_dir, manifest)?; invalidate_backup_list_cache(&full_backup_dir(), BackupListKind::Full); @@ -302,7 +303,7 @@ pub(in crate::server) fn create_full_backup_to_server( path = %root_dir.display(), bytes = size, automated = session_id.is_none(), - includes_tor_hidden_service_keys = include_tor_hidden_service_keys, + includes_tor_hidden_service_keys = tor_hidden_service_keys_included, "Backup v4 full backup created" ); progress @@ -340,12 +341,13 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Backup DB path non-UTF-8")))? .replace('\'', "''"); - let conn = pool.get()?; - conn.execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) + let snapshot_conn = pool.get()?; + snapshot_conn + .execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) .map_err(|error| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {error}")))?; - drop(conn); + drop(snapshot_conn); restrict_backup_file(&db_snapshot_path)?; - let conn = rusqlite::Connection::open_with_flags( + let export_conn = rusqlite::Connection::open_with_flags( &db_snapshot_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, )?; @@ -380,8 +382,8 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve ); } - let pre_integrity = snapshot_db_health_output(&conn, "integrity_check"); - let pre_foreign_key = snapshot_db_health_output(&conn, "foreign_key_check"); + let pre_integrity = snapshot_db_health_output(&export_conn, "integrity_check"); + let pre_foreign_key = snapshot_db_health_output(&export_conn, "foreign_key_check"); let repair_request_path = maintenance_dir.join("repair-request.json"); let repair_request = serde_json::json!({ @@ -428,7 +430,7 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve ); let schema_dump = { - let mut statement = conn + let mut statement = export_conn .prepare( "SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL @@ -460,7 +462,7 @@ pub(in crate::server::handlers::admin) fn create_pre_maintenance_backup_to_serve storage::sha256_hex_for_bytes(schema_dump.as_bytes()), ); - let pending_fs_ops = db::list_pending_fs_ops(&conn) + let pending_fs_ops = db::list_pending_fs_ops(&export_conn) .map_err(|error| AppError::Internal(anyhow::anyhow!("List pending_fs_ops: {error}")))?; if !pending_fs_ops.is_empty() { let pending_fs_path = maintenance_dir.join("pending-fs-ops.json"); @@ -773,7 +775,8 @@ pub(in crate::server) async fn create_full_backup( include_tor_hidden_service_keys, storage_mode, split_zip_part_size, - )?; + ) + .map(|_completed_value| ())?; Ok(()) } }) @@ -832,7 +835,7 @@ pub(in crate::server) async fn create_board_backup( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); log_backup_phase(crate::middleware::backup_phase::SNAPSHOT_DB); let manifest = build_board_backup_manifest(&conn, &board_short)?; @@ -1348,8 +1351,8 @@ where zip.write_all(manifest_json) .map_err(|error| AppError::Internal(anyhow::anyhow!("Write manifest: {error}")))?; if let Some(progress) = progress { - progress.files_done.fetch_add(1, Ordering::Relaxed); - progress.bytes_done.fetch_add( + let _files_before_manifest = progress.files_done.fetch_add(1, Ordering::Relaxed); + let _bytes_before_manifest = progress.bytes_done.fetch_add( u64::try_from(manifest_json.len()).unwrap_or(u64::MAX), Ordering::Relaxed, ); @@ -1509,11 +1512,16 @@ fn materialize_split_zip_parts( let parts_dir = root_dir.join(storage::PARTS_DIR_NAME); ensure_backup_dir(&parts_dir)?; let planned_parts = plan_split_zip_parts(&manifest.files, target_part_size); - let total_parts = u32::try_from(planned_parts.len()).unwrap_or(u32::MAX); + let total_parts = u32::try_from(planned_parts.len()).map_err(|error| { + AppError::Internal(anyhow::anyhow!("split part count exceeds u32: {error}")) + })?; let mut part_infos = Vec::with_capacity(planned_parts.len()); for (part_offset, planned) in planned_parts.iter().enumerate() { - let part_index = u32::try_from(part_offset + 1).unwrap_or(u32::MAX); + let part_index = u32::try_from(part_offset) + .ok() + .and_then(|part| part.checked_add(1)) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("split part index overflows")))?; let part_filename = format!("parts/part-{part_index:04}.zip"); let part_path = root_dir.join(&part_filename); let tmp_path = root_dir.join(format!("{part_filename}.tmp")); @@ -1545,12 +1553,14 @@ fn materialize_split_zip_parts( let mut source = std::fs::File::open(&source_path).map_err(|error| { AppError::Internal(anyhow::anyhow!("Open {}: {error}", source_path.display())) })?; - std::io::copy(&mut source, &mut zip).map_err(|error| { - AppError::Internal(anyhow::anyhow!( - "Copy {} into split ZIP: {error}", - source_path.display() - )) - })?; + std::io::copy(&mut source, &mut zip) + .map_err(|error| { + AppError::Internal(anyhow::anyhow!( + "Copy {} into split ZIP: {error}", + source_path.display() + )) + }) + .map(|_completed_value| ())?; } let writer = zip.finish().map_err(|error| { AppError::Internal(anyhow::anyhow!("Finalize {}: {error}", tmp_path.display())) @@ -1709,8 +1719,8 @@ where sha256, ); if let Some(progress) = progress { - progress.files_done.fetch_add(1, Ordering::Relaxed); - progress.bytes_done.fetch_add(size, Ordering::Relaxed); + let _files_before_upload = progress.files_done.fetch_add(1, Ordering::Relaxed); + let _bytes_before_upload = progress.bytes_done.fetch_add(size, Ordering::Relaxed); log_backup_progress(progress); } } @@ -1930,7 +1940,7 @@ mod tests { std::fs::write(&source, "cookie_secret = \"secret\"") .context("write source settings file")?; - copy_regular_file_to_backup(&source, &destination)?; + copy_regular_file_to_backup(&source, &destination).map(|_completed_value| ())?; let destination_mode = std::fs::metadata(&destination) .context("read destination metadata")? @@ -1969,7 +1979,8 @@ mod tests { let pool = crate::db::init_test_pool().context("create test pool")?; let conn = pool.get().context("get test database connection")?; crate::db::create_board(&conn, "tech", "Technology", "", false) - .context("create test board")?; + .context("create test board") + .map(|_completed_value| ())?; let db_path = temp_dir.path().join("snapshot.db"); let db_path_str = db_path @@ -2029,7 +2040,7 @@ mod tests { ) .context("add Tor keys to backup ZIP")?; } - zip.finish().context("finish backup ZIP")?; + drop(zip.finish().context("finish backup ZIP")?); Ok(()) } @@ -2038,7 +2049,8 @@ mod tests { let pool = crate::db::init_test_pool().context("create test pool")?; let conn = pool.get().context("get test database connection")?; crate::db::create_board(&conn, "tech", "Technology", "", false) - .context("create test board")?; + .context("create test board") + .map(|_completed_value| ())?; let manifest = build_full_backup_manifest(&conn, 1024, 5, 1, 2, false, 0)?; @@ -2155,7 +2167,8 @@ mod tests { let pool = crate::db::init_test_pool().context("create test pool")?; let conn = pool.get().context("get test database connection")?; crate::db::create_board(&conn, "tech", "Technology", "", false) - .context("create test board")?; + .context("create test board") + .map(|_completed_value| ())?; let invariant_trigger: String = conn .query_row( "SELECT sql FROM sqlite_schema @@ -2170,7 +2183,8 @@ mod tests { "UPDATE boards SET short_name = '../escape' WHERE short_name = 'tech'", [], ) - .context("corrupt stored board short name")?; + .context("corrupt stored board short name") + .map(|_completed_value| ())?; conn.execute_batch(&invariant_trigger) .context("restore board domain trigger")?; @@ -2250,7 +2264,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("Tor hidden service keys were requested")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -2282,7 +2307,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("Tor hidden service key backups are not available")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -2304,7 +2340,18 @@ mod tests { ensure!(message.contains("could not be read")); ensure!(message.contains(&missing.display().to_string())); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } diff --git a/src/handlers/admin/backup/downloads.rs b/src/handlers/admin/backup/downloads.rs index 7808350e..0fb5fdf7 100644 --- a/src/handlers/admin/backup/downloads.rs +++ b/src/handlers/admin/backup/downloads.rs @@ -171,7 +171,7 @@ pub(in crate::server) async fn download_backup( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(()) } }) @@ -302,7 +302,7 @@ pub(in crate::server) async fn backup_progress_json( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(()) } }) @@ -349,7 +349,7 @@ pub(in crate::server) async fn delete_backup( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let saved_root = crate::config::backups_dir().join(&safe_filename); let legacy_path = backup_dir.join(&safe_filename); @@ -360,8 +360,8 @@ pub(in crate::server) async fn delete_backup( invalidate_backup_list_cache(&backup_dir, backup_kind); tracing::info!(target: "admin", backup_ref = %safe_filename, "Backup directory deleted"); } else if legacy_path.exists() { - let legacy_path = safe_backup_file_path(&backup_dir, &safe_filename)?; - std::fs::remove_file(&legacy_path) + let validated_legacy_path = safe_backup_file_path(&backup_dir, &safe_filename)?; + std::fs::remove_file(&validated_legacy_path) .map_err(|e| AppError::Internal(anyhow::anyhow!("Delete backup: {e}")))?; invalidate_backup_list_cache(&backup_dir, backup_kind); tracing::info!(target: "admin", filename = %safe_filename, "Backup file deleted"); diff --git a/src/handlers/admin/backup/http.rs b/src/handlers/admin/backup/http.rs index 381fdc67..c9726ea3 100644 --- a/src/handlers/admin/backup/http.rs +++ b/src/handlers/admin/backup/http.rs @@ -46,7 +46,10 @@ pub(super) fn admin_xhr_error_response(error: &AppError) -> Response { AppError::NotFound(message) => Some((StatusCode::NOT_FOUND, message.clone())), AppError::BadRequest(message) => Some((StatusCode::BAD_REQUEST, message.clone())), AppError::Forbidden(message) => Some((StatusCode::FORBIDDEN, message.clone())), - AppError::BannedUser { reason, .. } => Some(( + AppError::BannedUser { + reason, + csrf_token: _, + } => Some(( StatusCode::FORBIDDEN, format!("You are banned. Reason: {reason}"), )), @@ -77,7 +80,17 @@ pub(super) fn admin_xhr_error_response(error: &AppError) -> Response { let (status, message) = match error { AppError::Internal(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), AppError::Tls(message) => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), - _ => ( + AppError::NotFound(_) + | AppError::BadRequest(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy => ( StatusCode::INTERNAL_SERVER_ERROR, "Unexpected admin restore error.".to_owned(), ), @@ -111,16 +124,18 @@ pub(super) fn redirect_page_response(target: &str, message: &str) -> Response { ); let mut resp = Response::new(axum::body::Body::from(body)); - resp.extensions_mut().insert(content); + drop(resp.extensions_mut().insert(content)); *resp.status_mut() = StatusCode::OK; - resp.headers_mut().insert( + drop(resp.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), - ); - resp.headers_mut().insert( - header::HeaderName::from_static("refresh"), - HeaderValue::from_str(&format!("0; url={target}")) - .unwrap_or_else(|_| HeaderValue::from_static("0; url=/admin/panel")), + )); + drop( + resp.headers_mut().insert( + header::HeaderName::from_static("refresh"), + HeaderValue::from_str(&format!("0; url={target}")) + .unwrap_or_else(|_| HeaderValue::from_static("0; url=/admin/panel")), + ), ); resp } @@ -345,7 +360,8 @@ pub(super) async fn restore_auth_preflight( let session_id_for_task = session_id.clone(); tokio::task::spawn_blocking(move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id_for_task.as_deref())?; + require_admin_session_sid(&conn, session_id_for_task.as_deref()) + .map(|_completed_value| ())?; Ok(()) }) .await @@ -639,7 +655,9 @@ mod tests { async fn parse_full_restore_upload( mut multipart: Multipart, ) -> crate::error::Result<&'static str> { - stream_restore_upload_to_tempfile(RestoreKind::Full, &mut multipart).await?; + stream_restore_upload_to_tempfile(RestoreKind::Full, &mut multipart) + .await + .map(|_completed_value| ())?; Ok("ok") } diff --git a/src/handlers/admin/backup/listing.rs b/src/handlers/admin/backup/listing.rs index a56ba8d9..ec4236c4 100644 --- a/src/handlers/admin/backup/listing.rs +++ b/src/handlers/admin/backup/listing.rs @@ -57,7 +57,7 @@ fn current_source_modified(dir: &Path) -> Option { } pub(super) fn invalidate_backup_list_cache(dir: &Path, kind: BackupListKind) { - BACKUP_LIST_CACHE + let _previous_value = BACKUP_LIST_CACHE .lock() .remove(&backup_list_cache_key(dir, kind)); } @@ -457,7 +457,7 @@ pub(in crate::server) fn list_backup_files(dir: &Path, kind: BackupListKind) -> .then_with(|| right.backup_ref.cmp(&left.backup_ref)) }); - BACKUP_LIST_CACHE.lock().insert( + let _previous_value = BACKUP_LIST_CACHE.lock().insert( cache_key, BackupListCacheEntry { generated_at: Instant::now(), diff --git a/src/handlers/admin/backup/restore_board.rs b/src/handlers/admin/backup/restore_board.rs index f918ac71..b18d255d 100644 --- a/src/handlers/admin/backup/restore_board.rs +++ b/src/handlers/admin/backup/restore_board.rs @@ -216,7 +216,8 @@ fn sync_autoincrement_sequence( AppError::Internal(anyhow::anyhow!( "Advance sqlite_sequence for {table} during restore: {error}" )) - })?; + }) + .map(|_completed_value| ())?; Ok(()) } None => { @@ -228,7 +229,8 @@ fn sync_autoincrement_sequence( AppError::Internal(anyhow::anyhow!( "Insert sqlite_sequence for {table} during restore: {error}" )) - })?; + }) + .map(|_completed_value| ())?; Ok(()) } } @@ -389,17 +391,24 @@ where conn.execute_batch(&format!("VACUUM INTO '{db_snapshot_str}'")) .map_err(|error| AppError::Internal(anyhow::anyhow!("Snapshot board DB: {error}")))?; conn.execute("BEGIN IMMEDIATE", []) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Begin tx: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Begin tx: {error}"))) + .map(|_completed_value| ())?; let restore_result = (|| -> Result<()> { let live_board_id: i64 = if let Some(existing_id) = existing_id { - conn.execute( - "DELETE FROM threads WHERE board_id = ?1", - params![existing_id], - ) - .map_err(|error| { - map_board_restore_sqlite_error(restore_label, &board_short, "Clear threads", &error) - })?; + let _removed_threads = conn + .execute( + "DELETE FROM threads WHERE board_id = ?1", + params![existing_id], + ) + .map_err(|error| { + map_board_restore_sqlite_error( + restore_label, + &board_short, + "Clear threads", + &error, + ) + })?; conn.execute( "UPDATE boards SET name=?1, description=?2, nsfw=?3, max_threads=?4, max_archived_threads=?5, bump_limit=?6, @@ -437,12 +446,13 @@ where existing_id, ], ) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Update board: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Update board: {error}"))).map(|_completed_value| ())?; conn.execute( "DELETE FROM banner_assets WHERE scope_type = 'board' AND board_id = ?1", params![existing_id], ) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Clear board banners: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Clear board banners: {error}"))) + .map(|_completed_value| ())?; existing_id } else { insert_returning_id( @@ -511,7 +521,8 @@ where banner.created_at, ], ) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Insert board banner: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Insert board banner: {error}"))) + .map(|_completed_value| ())?; } let preserve_thread_ids = can_reuse_row_ids( @@ -572,7 +583,7 @@ where .map_err(|error| { AppError::Internal(anyhow::anyhow!("Insert thread {}: {error}", thread.id)) })?; - thread_id_map.insert(thread.id, new_thread_id); + let _previous_value = thread_id_map.insert(thread.id, new_thread_id); } if preserve_thread_ids { sync_autoincrement_sequence( @@ -658,7 +669,7 @@ where .map_err(|error| { AppError::Internal(anyhow::anyhow!("Insert post {}: {error}", post.id)) })?; - post_id_map.insert(post.id, new_post_id); + let _previous_value = post_id_map.insert(post.id, new_post_id); } if preserve_post_ids { sync_autoincrement_sequence( @@ -693,7 +704,8 @@ where AppError::Internal(anyhow::anyhow!( "Fixup quotelinks for post {new_post_id}: {error}" )) - })?; + }) + .map(|_completed_value| ())?; } } } @@ -738,7 +750,7 @@ where .map_err(|error| { AppError::Internal(anyhow::anyhow!("Insert poll {}: {error}", poll.id)) })?; - poll_id_map.insert(poll.id, new_poll_id); + let _previous_value = poll_id_map.insert(poll.id, new_poll_id); } if preserve_poll_ids { sync_autoincrement_sequence( @@ -777,7 +789,7 @@ where .map_err(|error| { AppError::Internal(anyhow::anyhow!("Insert option {}: {error}", option.id)) })?; - option_id_map.insert(option.id, new_option_id); + let _previous_value = option_id_map.insert(option.id, new_option_id); } if preserve_option_ids { sync_autoincrement_sequence( @@ -809,7 +821,8 @@ where ) .map_err(|error| { AppError::Internal(anyhow::anyhow!("Insert vote {}: {error}", vote.id)) - })?; + }) + .map(|_completed_value| ())?; } for file_hash in &manifest.file_hashes { @@ -833,7 +846,8 @@ where AppError::Internal(anyhow::anyhow!( "Recompute restored thread reply counts: {error}" )) - })?; + }) + .map(|_completed_value| ())?; db::insert_pending_fs_op(conn, &workspace.pending_restore_op)?; Ok(()) @@ -842,7 +856,8 @@ where match restore_result { Ok(()) => { conn.execute("COMMIT", []) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Commit tx: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Commit tx: {error}"))) + .map(|_completed_value| ())?; if let Err(error) = crate::pending_fs::finalize_board_restore_payload( &workspace.pending_restore_payload, Path::new(upload_dir), @@ -938,7 +953,7 @@ pub(in crate::server) async fn extract_board_from_full_backup( let pool = state.db.clone(); move || -> Result { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let full_backup_dir_path = crate::config::backups_dir().join(&safe_filename); let full_backup_path = full_backup_dir().join(&safe_filename); @@ -1073,7 +1088,7 @@ pub(in crate::server) async fn restore_saved_board_backup( let pool = state.db.clone(); move || -> Result { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let root_dir = crate::config::backups_dir().join(&safe_filename); let legacy_zip_path = board_backup_dir().join(&safe_filename); let restore_archive_path = if root_dir.is_dir() { @@ -1183,7 +1198,8 @@ pub(in crate::server) async fn board_restore( use std::io::Read as _; let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; let mut magic = [0u8; 4]; let mut probe = zip_tmp diff --git a/src/handlers/admin/backup/restore_full.rs b/src/handlers/admin/backup/restore_full.rs index 8abe3a91..a0b2a06e 100644 --- a/src/handlers/admin/backup/restore_full.rs +++ b/src/handlers/admin/backup/restore_full.rs @@ -78,8 +78,8 @@ fn validate_full_restore_db_trust_boundary(conn: &rusqlite::Connection) -> Resul )) })?; validate_board_short_name(&short_name)?; - valid_board_shorts.insert(short_name.clone()); - board_ids_to_shorts.insert(board_id, short_name); + let _completed_value = valid_board_shorts.insert(short_name.clone()); + let _previous_value = board_ids_to_shorts.insert(board_id, short_name); } let mut post_stmt = conn @@ -223,14 +223,16 @@ fn recompute_restored_post_body_html(conn: &rusqlite::Connection) -> Result<()> AppError::Internal(anyhow::anyhow!( "Update restored body_html for post {post_id}: {error}" )) - })?; + }) + .map(|_completed_value| ())?; } Ok(()) } fn scrub_full_restore_runtime_state(conn: &rusqlite::Connection) -> Result<()> { conn.execute("DELETE FROM admin_sessions", []) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Clear restored sessions: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Clear restored sessions: {error}"))) + .map(|_completed_value| ())?; Ok(()) } @@ -390,6 +392,22 @@ fn create_live_restore_snapshot( Ok(()) } +/// Validate expiry across Chrono's entire timestamp range before restore mutation. +/// This permits calculating the full lifetime at issuance even if the clock moves. +fn validate_restored_session_duration(duration: i64) -> Result<()> { + let _earliest_expiry = + restored_session_expiry(chrono::DateTime::::MIN_UTC.timestamp(), duration)?; + let _latest_expiry = + restored_session_expiry(chrono::DateTime::::MAX_UTC.timestamp(), duration)?; + Ok(()) +} + +/// Compute a checked replacement-session expiry at the actual issuance time. +fn restored_session_expiry(now: i64, duration: i64) -> Result { + now.checked_add(duration) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("restored session expiry overflows"))) +} + #[expect( clippy::cognitive_complexity, reason = "full restore validation, replacement, and rollback remain one fail-closed operation" @@ -413,6 +431,7 @@ pub(super) fn execute_full_restore( ) -> Result { validate_full_restore_archive_layout(archive)?; let manifest = verify_full_backup_archive(archive)?; + validate_restored_session_duration(CONFIG.session_duration)?; if restore_tor_hidden_service_keys && !manifest.tor_hidden_service_keys_included { return Err(AppError::BadRequest( "This backup does not include Tor hidden service keys.".into(), @@ -523,7 +542,8 @@ pub(super) fn execute_full_restore( RESTORE_TOTAL_EXTRACTED_MAX_BYTES, "Full restore archive", ) - .map_err(|error| AppError::Internal(anyhow::anyhow!("Write temp DB: {error}")))?; + .map_err(|error| AppError::Internal(anyhow::anyhow!("Write temp DB: {error}"))) + .map(|_completed_value| ())?; restrict_restore_temp_file(&temp_db)?; let mut header = [0u8; 16]; @@ -571,7 +591,8 @@ pub(super) fn execute_full_restore( ) .map_err(|error| { AppError::Internal(anyhow::anyhow!("Write {}: {error}", target.display())) - })?; + }) + .map(|_completed_value| ())?; } } else if let Some(rel_path) = validate_entry_path_under_prefix(&name, "favicon/")? { favicon_extracted = true; @@ -599,7 +620,8 @@ pub(super) fn execute_full_restore( ) .map_err(|error| { AppError::Internal(anyhow::anyhow!("Write {}: {error}", target.display())) - })?; + }) + .map(|_completed_value| ())?; } } else if let Some(rel) = name.strip_prefix("banner/") { if rel.is_empty() { @@ -636,7 +658,8 @@ pub(super) fn execute_full_restore( ) .map_err(|error| { AppError::Internal(anyhow::anyhow!("Write {}: {error}", target.display())) - })?; + }) + .map(|_completed_value| ())?; } else if let (Some(rel_path), Some(staged_tor_hidden_service_keys_dir)) = ( validate_entry_path_under_prefix(&name, safety::FULL_BACKUP_TOR_KEYS_ENTRY_PREFIX)?, staged_tor_hidden_service_keys_dir.as_ref(), @@ -665,7 +688,8 @@ pub(super) fn execute_full_restore( ) .map_err(|error| { AppError::Internal(anyhow::anyhow!("Write {}: {error}", target.display())) - })?; + }) + .map(|_completed_value| ())?; tor_hidden_service_key_files_extracted = tor_hidden_service_key_files_extracted.saturating_add(1); } @@ -829,7 +853,7 @@ pub(super) fn execute_full_restore( drop(std::fs::remove_file(&db_snapshot)); let fresh_sid = new_session_id(); - let expires_at = Utc::now().timestamp() + CONFIG.session_duration; + let expires_at = restored_session_expiry(Utc::now().timestamp(), CONFIG.session_duration)?; let session_result = match db::create_session(live_conn, &fresh_sid, admin_id, expires_at) { Ok(()) => { tracing::info!(target: "admin", admin_id = admin_id, "{completion_log}"); @@ -952,7 +976,8 @@ pub(in crate::server) async fn admin_restore( let session_id = restore_auth_preflight(&state, &headers, &jar, secure_context.peer).await?; let upload = stream_restore_upload_to_tempfile(RestoreKind::Full, &mut multipart).await?; - validate_streamed_restore_upload(RestoreKind::Full, &jar, &upload)?; + validate_streamed_restore_upload(RestoreKind::Full, &jar, &upload) + .map(|_completed_value| ())?; let zip_tmp = upload.temp_file; let restore_tor_hidden_service_keys = upload.restore_tor_hidden_service_keys; let uploaded_filename = upload.uploaded_filename; @@ -1144,6 +1169,66 @@ pub(in crate::server) async fn restore_saved_full_backup( #[cfg(test)] mod tests { + #[test] + fn restore_preflight_bounds_session_duration_for_any_clock_value() -> anyhow::Result<()> { + use anyhow::Context as _; + let minimum = chrono::DateTime::::MIN_UTC.timestamp(); + let maximum = chrono::DateTime::::MAX_UTC.timestamp(); + super::validate_restored_session_duration(3600)?; + let longest = i64::MAX + .checked_sub(maximum) + .context("maximum lifetime boundary overflow")?; + super::validate_restored_session_duration(longest)?; + anyhow::ensure!( + super::restored_session_expiry(maximum, longest)? == i64::MAX, + "maximum boundary changed" + ); + anyhow::ensure!( + super::validate_restored_session_duration( + longest + .checked_add(1) + .context("lifetime increment overflow")? + ) + .is_err(), + "overflowing future expiry was accepted" + ); + let shortest = i64::MIN + .checked_sub(minimum) + .context("minimum lifetime boundary overflow")?; + super::validate_restored_session_duration(shortest)?; + anyhow::ensure!( + super::restored_session_expiry(minimum, shortest)? == i64::MIN, + "minimum boundary changed" + ); + anyhow::ensure!( + super::validate_restored_session_duration( + shortest + .checked_sub(1) + .context("lifetime decrement overflow")? + ) + .is_err(), + "underflowing past expiry was accepted" + ); + Ok(()) + } + + #[test] + fn restored_session_expiry_rejects_overflow() -> anyhow::Result<()> { + anyhow::ensure!( + super::restored_session_expiry(100, 3600)? == 3700, + "ordinary session duration changed" + ); + anyhow::ensure!( + super::restored_session_expiry(i64::MAX, 1).is_err(), + "overflowing session expiry was accepted" + ); + anyhow::ensure!( + super::restored_session_expiry(i64::MIN, -1).is_err(), + "underflowing session expiry was accepted" + ); + Ok(()) + } + use super::{ execute_full_restore, full_restore_success_response, validate_full_restore_db_trust_boundary, @@ -1178,7 +1263,7 @@ mod tests { impl Drop for FullRestoreRuntimeTmpRootOverride { fn drop(&mut self) { - super::FULL_RESTORE_RUNTIME_TMP_ROOT_FOR_TEST + let _previous_value = super::FULL_RESTORE_RUNTIME_TMP_ROOT_FOR_TEST .with(|root| root.replace(self.previous.take())); } } @@ -1189,7 +1274,8 @@ mod tests { let pool = crate::db::init_test_pool().context("create test database pool")?; let conn = pool.get().context("get test database connection")?; crate::db::create_board(&conn, "tech", "Technology", "", false) - .context("create fixture board")?; + .context("create fixture board") + .map(|_completed_value| ())?; let db_path_str = db_path .to_str() .context("snapshot fixture path is not UTF-8")? @@ -1344,7 +1430,7 @@ mod tests { }); let manifest_json = if legacy_manifest { serde_json::json!({ - "version": 2, + "version": 2_i32, "generated_at": 1_700_000_000_i64, "rustchan_version": "1.1.3", "db_bytes": db_bytes.len(), @@ -1355,7 +1441,7 @@ mod tests { }) } else { serde_json::json!({ - "version": 3, + "version": 3_i32, "generated_at": 1_700_000_000_i64, "rustchan_version": "1.1.3", "db_bytes": db_bytes.len(), @@ -1395,7 +1481,7 @@ mod tests { .context("write Tor key entry")?; } } - zip.finish().context("finish backup ZIP")?; + drop(zip.finish().context("finish backup ZIP")?); Ok(()) } @@ -1460,7 +1546,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; ensure_full_restore_temp_dir_empty(&runtime_tmp_root) } @@ -1485,7 +1572,7 @@ mod tests { .replace('\\', "/"); let contents = std::fs::read_to_string(&path) .with_context(|| format!("read tree file {}", path.display()))?; - out.insert(rel, contents); + let _previous_value = out.insert(rel, contents); } } Ok(()) @@ -1502,7 +1589,7 @@ mod tests { fn saved_full_restore_success_response_sets_session_cookie_and_reopens_section( ) -> TestResult<()> { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("localhost")); + let _previous_value = headers.insert(header::HOST, HeaderValue::from_static("localhost")); let response = full_restore_success_response( CookieJar::new(), @@ -1574,7 +1661,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; let live_tree = read_tree(&tor_keys_dir)?; ensure!( @@ -1620,7 +1708,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; Ok(()) } @@ -1635,13 +1724,15 @@ mod tests { VALUES (1, 'restored-admin', 'restored-hash')", [], ) - .context("seed restored administrator")?; + .context("seed restored administrator") + .map(|_completed_value| ())?; conn.execute( "INSERT INTO admin_sessions (id, admin_id, expires_at) VALUES ('stale-session-from-backup', 1, unixepoch() + 86400)", [], ) - .context("seed stale session")?; + .context("seed stale session") + .map(|_completed_value| ())?; } let zip_path = temp_dir.path().join("backup.zip"); write_full_backup_zip_from_db(&zip_path, &db_path, None, false)?; @@ -1720,7 +1811,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; let body_html: String = live_conn .query_row( @@ -1751,7 +1843,8 @@ mod tests { conn.execute_batch("DROP TRIGGER boards_domain_update") .context("temporarily remove board domain trigger")?; conn.execute("UPDATE boards SET short_name = '../admin'", []) - .context("seed invalid board short name")?; + .context("seed invalid board short name") + .map(|_completed_value| ())?; conn.execute_batch(&invariant_trigger) .context("restore board domain trigger")?; } @@ -1785,7 +1878,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("boards has 1 row(s)")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1808,7 +1912,7 @@ mod tests { VALUES (1, ?1, 'ghost', unixepoch(), unixepoch(), 0, 0, 0, 0)", [board_id], ) - .context("seed thread")?; + .context("seed thread").map(|_completed_value| ())?; conn.execute( "INSERT INTO posts (id, thread_id, board_id, name, body, body_html, file_path, file_name, file_size, @@ -1818,13 +1922,14 @@ mod tests { 'ghost/thumbs/doc.svg', 'application/pdf', 'pdf', unixepoch(), 'token', 1)", [board_id], ) - .context("seed invalid restored media path")?; + .context("seed invalid restored media path") + .map(|_completed_value| ())?; conn.execute( "INSERT INTO file_hashes (sha256, file_path, thumb_path, mime_type, created_at) VALUES ('ghost-hash', 'ghost/doc.pdf', 'ghost/thumbs/doc.svg', 'application/pdf', unixepoch())", [], ) - .context("seed invalid restored file-hash path")?; + .context("seed invalid restored file-hash path").map(|_completed_value| ())?; } let zip_path = temp_dir.path().join("backup.zip"); write_full_backup_zip_from_db(&zip_path, &db_path, None, false)?; @@ -1856,7 +1961,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("points to unknown board /ghost/")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1868,7 +1984,8 @@ mod tests { { let conn = rusqlite::Connection::open(&db_path).context("open database snapshot")?; crate::db::create_board(&conn, "b", "Random", "", false) - .context("create second board")?; + .context("create second board") + .map(|_completed_value| ())?; let tech_board_id: i64 = conn .query_row( "SELECT id FROM boards WHERE short_name = 'tech'", @@ -1881,7 +1998,7 @@ mod tests { VALUES (1, ?1, 'doc', unixepoch(), unixepoch(), 0, 0, 0, 0)", [tech_board_id], ) - .context("seed thread")?; + .context("seed thread").map(|_completed_value| ())?; conn.execute( "INSERT INTO posts (id, thread_id, board_id, name, body, body_html, file_path, file_name, file_size, @@ -1891,13 +2008,14 @@ mod tests { 'b/thumbs/doc.svg', 'application/pdf', 'pdf', unixepoch(), 'token', 1)", [tech_board_id], ) - .context("seed cross-board thumbnail path")?; + .context("seed cross-board thumbnail path") + .map(|_completed_value| ())?; conn.execute( "INSERT INTO file_hashes (sha256, file_path, thumb_path, mime_type, created_at) VALUES ('cross-board-hash', 'tech/doc.pdf', 'b/thumbs/doc.svg', 'application/pdf', unixepoch())", [], ) - .context("seed cross-board file hash")?; + .context("seed cross-board file hash").map(|_completed_value| ())?; } let zip_path = temp_dir.path().join("backup.zip"); write_full_backup_zip_from_db(&zip_path, &db_path, None, false)?; @@ -1929,7 +2047,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("escapes its board /tech/")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1943,7 +2072,8 @@ mod tests { VALUES ('generic-hash', 'tech/file.bin', '', 'application/octet-stream', unixepoch())", [], ) - .context("seed generic file hash")?; + .context("seed generic file hash") + .map(|_completed_value| ())?; validate_full_restore_db_trust_boundary(&conn)?; Ok(()) @@ -1953,13 +2083,15 @@ mod tests { fn full_restore_trust_boundary_rejects_cross_board_file_hash_pairing() -> TestResult<()> { let db_path = create_snapshot_db()?; let conn = rusqlite::Connection::open(&db_path).context("open database snapshot")?; - crate::db::create_board(&conn, "b", "Random", "", false).context("create second board")?; + crate::db::create_board(&conn, "b", "Random", "", false) + .context("create second board") + .map(|_completed_value| ())?; conn.execute( "INSERT INTO file_hashes (sha256, file_path, thumb_path, mime_type, created_at) VALUES ('cross-board-hash', 'tech/doc.pdf', 'b/thumbs/doc.svg', 'application/pdf', unixepoch())", [], ) - .context("seed cross-board file hash")?; + .context("seed cross-board file hash").map(|_completed_value| ())?; let error = validate_full_restore_db_trust_boundary(&conn) .err() @@ -1969,7 +2101,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("mixes boards between file_path and thumb_path")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -2014,7 +2157,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("Tor hidden service key restore is not available")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -2055,7 +2209,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; ensure!( read_tree(&tor_keys_dir)? @@ -2117,7 +2272,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; let live_tree = read_tree(&tor_keys_dir)?; ensure!( @@ -2264,7 +2420,18 @@ mod tests { crate::error::AppError::BadRequest(message) => { ensure!(message.contains("does not include Tor hidden service keys")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (crate::error::AppError::NotFound(_) + | crate::error::AppError::Forbidden(_) + | crate::error::AppError::BannedUser { + reason: _, + csrf_token: _, + } + | crate::error::AppError::UploadTooLarge(_) + | crate::error::AppError::InvalidMediaType(_) + | crate::error::AppError::Conflict(_) + | crate::error::AppError::DbBusy + | crate::error::AppError::Internal(_) + | crate::error::AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -2298,7 +2465,8 @@ mod tests { "Test restore completed", "Test restore", "Test restore", - )?; + ) + .map(|_completed_value| ())?; ensure!( read_tree(&tor_keys_dir)? diff --git a/src/handlers/admin/backup/safety.rs b/src/handlers/admin/backup/safety.rs index ef41e02d..51835ce3 100644 --- a/src/handlers/admin/backup/safety.rs +++ b/src/handlers/admin/backup/safety.rs @@ -51,12 +51,14 @@ pub(super) fn resolve_tor_hidden_service_keys_availability( return Err(AppError::BadRequest(unavailable_message.to_owned())); }; - std::fs::read_dir(&dir).map_err(|error| { - AppError::BadRequest(format!( + std::fs::read_dir(&dir) + .map_err(|error| { + AppError::BadRequest(format!( "{unavailable_message} The configured identity directory {} could not be read: {error}", dir.display() )) - })?; + }) + .map(|_completed_value| ())?; Ok(TorHiddenServiceKeysAvailability::Available(dir)) } @@ -130,9 +132,13 @@ pub(super) fn log_backup_progress(progress: &BackupProgress) { return; } - let percent = done.saturating_mul(100) / total.max(1); + let Some(percent) = done.saturating_mul(100).checked_div(total) else { + return; + }; let prev_done = done.saturating_sub(1); - let prev_percent = prev_done.saturating_mul(100) / total.max(1); + let Some(prev_percent) = prev_done.saturating_mul(100).checked_div(total) else { + return; + }; let should_log = total <= 50 || done == 1 || done == total @@ -215,8 +221,8 @@ fn remap_numeric_references(body: &str, prefix: &str, pairs: &[(String, String)] break; } Some(rel) => { - let abs = pos + rel; - let after = abs + needle.len(); + let abs = pos.saturating_add(rel); + let after = abs.saturating_add(needle.len()); let next_is_digit = bytes.get(after).is_some_and(u8::is_ascii_digit); let Some(before_match) = remaining.get(..rel) else { break; @@ -345,9 +351,9 @@ pub(super) fn read_limited_bytes( label: &str, ) -> Result> { let mut bytes = Vec::new(); - copy_limited(reader, &mut bytes, max_bytes).map_err(|error| { - AppError::BadRequest(format!("{label} exceeds safe size limit: {error}")) - })?; + copy_limited(reader, &mut bytes, max_bytes) + .map_err(|error| AppError::BadRequest(format!("{label} exceeds safe size limit: {error}"))) + .map(|_completed_value| ())?; Ok(bytes) } @@ -401,7 +407,8 @@ pub(super) fn extract_uploads_to_dir( RESTORE_TOTAL_EXTRACTED_MAX_BYTES, "Restored uploads", ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write {}: {e}", target.display())))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write {}: {e}", target.display()))) + .map(|_completed_value| ())?; } Ok(()) } @@ -427,7 +434,10 @@ pub(super) fn validate_restore_safe_entry_name(name: &str) -> Result<()> { for component in Path::new(name).components() { match component { std::path::Component::Normal(_) => {} - _ => { + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::CurDir + | std::path::Component::ParentDir => { return Err(AppError::BadRequest(format!( "Backup contains suspicious path '{name}'" ))); @@ -655,7 +665,7 @@ mod tests { zip.write_all(bytes) .with_context(|| format!("write ZIP entry {name}"))?; } - zip.finish().context("finish ZIP archive")?; + drop(zip.finish().context("finish ZIP archive")?); Ok(()) } @@ -664,7 +674,8 @@ mod tests { let db_path = temp_dir.path().join("partial.sqlite3"); let conn = rusqlite::Connection::open(&db_path).context("open SQLite database")?; conn.execute("CREATE TABLE boards (id INTEGER PRIMARY KEY)", []) - .context("create partial boards table")?; + .context("create partial boards table") + .map(|_completed_value| ())?; drop(conn); std::fs::read(db_path).context("read partial SQLite database") } @@ -717,7 +728,7 @@ mod tests { zip.start_file("uploads/../../escape.txt", options) .context("start suspicious ZIP entry")?; std::io::Write::write_all(&mut zip, b"bad").context("write suspicious ZIP entry")?; - zip.finish().context("finish ZIP archive")?; + drop(zip.finish().context("finish ZIP archive")?); } let file = std::fs::File::open(&zip_path).context("open ZIP file")?; @@ -767,7 +778,7 @@ mod tests { zip.start_file("uploads/test/ok.txt", options) .context("start valid ZIP entry")?; std::io::Write::write_all(&mut zip, b"ok").context("write valid ZIP entry")?; - zip.finish().context("finish ZIP archive")?; + drop(zip.finish().context("finish ZIP archive")?); } let file = std::fs::File::open(&zip_path).context("open ZIP file")?; @@ -922,7 +933,7 @@ mod tests { let zip_path = temp_dir.path().join("legacy-full.zip"); let db_bytes = super::super::storage::database_snapshot_fixture()?; let manifest = json!({ - "version": 2, + "version": 2_i32, "generated_at": 1_700_000_000_i64, "rustchan_version": "1.1.3", "db_bytes": u64::try_from(db_bytes.len()).context("convert database size")?, @@ -985,30 +996,30 @@ mod tests { let temp_dir = tempfile::tempdir().context("create temporary directory")?; let zip_path = temp_dir.path().join("board.zip"); let manifest = json!({ - "version": 1, + "version": 1_i32, "board": { - "id": 1, + "id": 1_i32, "short_name": "b", "name": "Random", "description": "", "nsfw": false, - "max_threads": 100, - "max_archived_threads": 150, - "bump_limit": 300, + "max_threads": 100_i32, + "max_archived_threads": 150_i32, + "bump_limit": 300_i32, "allow_images": true, "allow_video": true, "allow_audio": true, "allow_any_files": false, "allow_tripcodes": true, - "edit_window_secs": 300, + "edit_window_secs": 300_i32, "allow_editing": true, "allow_archive": true, "allow_video_embeds": true, "allow_captcha": false, "show_poster_ids": false, "collapse_greentext": true, - "post_cooldown_secs": 0, - "created_at": 1_700_000_000 + "post_cooldown_secs": 0_i32, + "created_at": 1_700_000_000_i32 }, "threads": [], "posts": [], diff --git a/src/handlers/admin/backup/storage.rs b/src/handlers/admin/backup/storage.rs index 98dc3286..0f23271c 100644 --- a/src/handlers/admin/backup/storage.rs +++ b/src/handlers/admin/backup/storage.rs @@ -4,7 +4,6 @@ use crate::error::{AppError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use std::collections::{HashMap, HashSet}; -use std::fmt::Write as _; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -291,7 +290,8 @@ pub(super) fn build_backup_id(_scope: BackupScope, scope_label: &str) -> String pub(super) fn create_backup_root(backup_id: &str) -> Result { if let Some(path) = &crate::config::CONFIG.backup_directory { crate::config::prepare_backup_directory(path, &crate::config::CONFIG) - .map_err(|error| AppError::BadRequest(format!("{error:#}")))?; + .map_err(|error| AppError::BadRequest(format!("{error:#}"))) + .map(|_completed_value| ())?; } let root = backups_root_dir().join(backup_id); crate::config::ensure_private_dir(&root).map_err(|error| { @@ -510,35 +510,50 @@ pub(super) fn build_readme( has_tor_keys: bool, ) -> String { let mut out = String::new(); - let _ = writeln!(out, "RustChan Backup v4"); - let _ = writeln!(out); - let _ = writeln!(out, "Backup ID: {}", manifest.backup_id); - let _ = writeln!(out, "Scope: {}", manifest.scope.slug()); - let _ = writeln!(out, "Mode: {}", metadata.storage_mode.display_name()); - let _ = writeln!(out, "Archive container: {}", manifest.archive_container); - let _ = writeln!( - out, - "Tor keys included: {}", - if has_tor_keys { "yes" } else { "no" } + crate::templates::append_html(&mut out, format_args!("RustChan Backup v4\n")); + crate::templates::append_html(&mut out, format_args!("\n")); + crate::templates::append_html( + &mut out, + format_args!("Backup ID: {}\n", manifest.backup_id), ); - let _ = writeln!(out); - let _ = writeln!(out, "Important files:"); - let _ = writeln!(out, "- {BACKUP_METADATA_FILE_NAME}"); - let _ = writeln!(out, "- {MANIFEST_FILE_NAME}"); - let _ = writeln!(out, "- {CHECKSUMS_FILE_NAME}"); + crate::templates::append_html(&mut out, format_args!("Scope: {}\n", manifest.scope.slug())); + crate::templates::append_html( + &mut out, + format_args!("Mode: {}\n", metadata.storage_mode.display_name()), + ); + crate::templates::append_html( + &mut out, + format_args!("Archive container: {}\n", manifest.archive_container), + ); + crate::templates::append_html( + &mut out, + format_args!( + "Tor keys included: {}\n", + if has_tor_keys { "yes" } else { "no" } + ), + ); + crate::templates::append_html(&mut out, format_args!("\n")); + crate::templates::append_html(&mut out, format_args!("Important files:\n")); + crate::templates::append_html(&mut out, format_args!("- {BACKUP_METADATA_FILE_NAME}\n")); + crate::templates::append_html(&mut out, format_args!("- {MANIFEST_FILE_NAME}\n")); + crate::templates::append_html(&mut out, format_args!("- {CHECKSUMS_FILE_NAME}\n")); if metadata.storage_mode == BackupStorageMode::SplitZip { - let _ = writeln!(out, "- {PARTS_DIR_NAME}/"); + crate::templates::append_html(&mut out, format_args!("- {PARTS_DIR_NAME}/\n")); } if has_tor_keys { - let _ = writeln!(out, "- tor-keys/"); - } - let _ = writeln!(out); - let _ = writeln!(out, "Keep all files in this backup folder together."); - let _ = writeln!(out, "Do not mix ZIP parts from different backups."); - let _ = writeln!( - out, - "Use RustChan restore for safe programmatic restore. Standard ZIP tools are useful only for manual inspection and emergency extraction." + crate::templates::append_html(&mut out, format_args!("- tor-keys/\n")); + } + crate::templates::append_html(&mut out, format_args!("\n")); + crate::templates::append_html( + &mut out, + format_args!("Keep all files in this backup folder together.\n"), + ); + crate::templates::append_html( + &mut out, + format_args!("Do not mix ZIP parts from different backups.\n"), ); + crate::templates::append_html(&mut out, format_args!("Use RustChan restore for safe programmatic restore. Standard ZIP tools are useful only for manual inspection and emergency extraction.\n" + )); out } @@ -555,7 +570,7 @@ pub(super) fn write_root_checksums(root_dir: &Path, extra_paths: &[&Path]) -> Re continue; } let sha256 = sha256_hex_for_file(&path)?; - let _ = writeln!(lines, "{sha256} {name}"); + crate::templates::append_html(&mut lines, format_args!("{sha256} {name}\n")); } for path in extra_paths { if !path.is_file() { @@ -563,7 +578,10 @@ pub(super) fn write_root_checksums(root_dir: &Path, extra_paths: &[&Path]) -> Re } let sha256 = sha256_hex_for_file(path)?; let rel = path.strip_prefix(root_dir).unwrap_or(path); - let _ = writeln!(lines, "{} {}", sha256, rel.to_string_lossy()); + crate::templates::append_html( + &mut lines, + format_args!("{} {}\n", sha256, rel.to_string_lossy()), + ); } write_text(&root_dir.join(CHECKSUMS_FILE_NAME), &lines) } @@ -967,9 +985,9 @@ pub(super) fn copy_verified_file_to_writer( let mut source = std::fs::File::open(path).map_err(|error| { AppError::Internal(anyhow::anyhow!("Open {}: {error}", path.display())) })?; - std::io::copy(&mut source, writer).map_err(|error| { - AppError::Internal(anyhow::anyhow!("Copy verified file: {error}")) - })?; + std::io::copy(&mut source, writer) + .map_err(|error| AppError::Internal(anyhow::anyhow!("Copy verified file: {error}"))) + .map(|_completed_value| ())?; } VerifiedFileSource::ZipEntry { part_path, @@ -987,11 +1005,13 @@ pub(super) fn copy_verified_file_to_writer( "Verified Backup v4 ZIP entry '{entry_path}' is no longer readable: {error}" )) })?; - std::io::copy(&mut entry, writer).map_err(|error| { - AppError::Internal(anyhow::anyhow!( - "Copy verified Backup v4 ZIP entry '{entry_path}': {error}" - )) - })?; + std::io::copy(&mut entry, writer) + .map_err(|error| { + AppError::Internal(anyhow::anyhow!( + "Copy verified Backup v4 ZIP entry '{entry_path}': {error}" + )) + }) + .map(|_completed_value| ())?; } } Ok(()) @@ -1116,7 +1136,7 @@ fn verify_split_part_path(root_dir: &Path, filename: &str) -> Result { "Backup v4 split part path '{filename}' must be an immediate file under parts/." ))); } - parse_split_part_index(filename)?; + parse_split_part_index(filename).map(|_completed_value| ())?; resolve_declared_file(root_dir, filename, "Backup v4 split ZIP part") } @@ -1245,8 +1265,8 @@ fn verify_split_zip_files( part.filename ))); } - allowed_files.insert(part.filename.clone()); - part_paths.insert(part.filename.clone(), part_path); + let _completed_value = allowed_files.insert(part.filename.clone()); + let _previous_value = part_paths.insert(part.filename.clone(), part_path); } for expected_index in 1..=expected_total { if !part_indexes.contains(&expected_index) { @@ -1303,7 +1323,7 @@ fn verify_split_zip_files( entry.size, &entry.sha256, )?; - verified_files.insert(entry.logical_path.clone(), verified); + let _previous_value = verified_files.insert(entry.logical_path.clone(), verified); } let mut sorted_part_paths: Vec<_> = part_paths.iter().collect(); @@ -1362,7 +1382,8 @@ fn verify_split_zip_files( entry_path: entry_name, }, )?; - verified_files.insert(manifest_entry.logical_path.clone(), verified); + let _previous_value = + verified_files.insert(manifest_entry.logical_path.clone(), verified); } let mut expected_entry_names: Vec<_> = expected_entries.keys().collect(); expected_entry_names.sort(); @@ -1540,7 +1561,7 @@ pub(super) fn verify_saved_backup( entry.size, &entry.sha256, )?; - verified_files.insert(entry.logical_path.clone(), verified); + let _previous_value = verified_files.insert(entry.logical_path.clone(), verified); } (verified_files, HashSet::new()) }; @@ -1626,7 +1647,7 @@ pub(super) fn verify_saved_backup( allowed_files.extend(split_part_allowed_files); if let Some(snapshot) = &db_snapshot { if verified_file_is_root_stored(&snapshot.file) { - allowed_files.insert(snapshot.file.logical_path.clone()); + let _completed_value = allowed_files.insert(snapshot.file.logical_path.clone()); } } @@ -1648,7 +1669,7 @@ pub(super) fn verify_saved_backup( sorted_verified_files.sort_by(|left, right| left.logical_path.cmp(&right.logical_path)); for verified in sorted_verified_files { if verified_file_is_root_stored(verified) { - allowed_files.insert(verified.logical_path.clone()); + let _completed_value = allowed_files.insert(verified.logical_path.clone()); } if let Some(board_short) = verified.board.as_deref() { if !included_boards.contains_key(board_short) { @@ -1783,7 +1804,7 @@ pub(super) fn verify_saved_backup( )) })?; validate_board_json_identity(&board_json_file, board_short, &board_summary.name)?; - boards.insert( + let _previous_value = boards.insert( board_short.clone(), VerifiedBoard { board_json: board_json_file, @@ -1918,7 +1939,9 @@ pub(super) fn write_saved_backup_fixture( .and_then(|name| name.to_str()) .context("saved-backup fixture root has no UTF-8 backup ID")? .to_owned(); - let created_at = completed_at - 60; + let created_at = completed_at + .checked_sub(60) + .context("backup fixture creation timestamp underflows")?; let mut manifest_files = Vec::new(); let includes_database = db_snapshot.is_some(); let db_snapshot_info = db_snapshot.as_deref().map(|bytes| DatabaseSnapshotInfo { @@ -2142,7 +2165,12 @@ mod tests { let total_parts = u32::try_from(groups.len()).context("split part count exceeds u32")?; for (offset, (file_indexes, oversized)) in groups.iter().enumerate() { - let part_index = u32::try_from(offset + 1).context("split part index exceeds u32")?; + let part_index = u32::try_from( + offset + .checked_add(1) + .context("split part index overflows")?, + ) + .context("split part index exceeds u32")?; let part_filename = format!("parts/part-{part_index:04}.zip"); let part_path = root.join(&part_filename); let file = std::fs::File::create(&part_path) @@ -2161,9 +2189,11 @@ mod tests { .context("start split-part ZIP entry")?; let mut source = std::fs::File::open(&source_path) .with_context(|| format!("open fixture file {}", source_path.display()))?; - std::io::copy(&mut source, &mut zip).context("copy split-part ZIP entry")?; + std::io::copy(&mut source, &mut zip) + .context("copy split-part ZIP entry") + .map(|_completed_value| ())?; } - zip.finish().context("finish split-part ZIP")?; + drop(zip.finish().context("finish split-part ZIP")?); for file_index in file_indexes { let entry = manifest .files @@ -2233,7 +2263,8 @@ mod tests { let mut bytes = Vec::new(); zip_entry .read_to_end(&mut bytes) - .context("read split-part ZIP entry contents")?; + .context("read split-part ZIP entry contents") + .map(|_completed_value| ())?; if name == entry_path { bytes = replacement.to_vec(); } @@ -2249,7 +2280,7 @@ mod tests { zip.write_all(&bytes) .context("write rewritten split-part entry")?; } - zip.finish().context("finish rewritten split part")?; + drop(zip.finish().context("finish rewritten split part")?); let part = manifest .parts @@ -2273,7 +2304,8 @@ mod tests { board_file_fixtures(), Some(database_snapshot_fixture()?), 1_715_000_000_i64, - )?; + ) + .map(|_completed_value| ())?; let verified = verify_saved_backup(&root, &[BackupScope::FullSite])?; ensure!(verified.completed_at == 1_715_000_000_i64); @@ -2397,7 +2429,8 @@ mod tests { board_file_fixtures(), Some(database_snapshot_fixture()?), 1_715_000_100_i64, - )?; + ) + .map(|_completed_value| ())?; std::fs::write(root.join("boards/tech/media/src/example.txt"), b"other") .context("tamper fixture file")?; @@ -2419,7 +2452,8 @@ mod tests { board_file_fixtures(), Some(database_snapshot_fixture()?), 1_715_000_200_i64, - )?; + ) + .map(|_completed_value| ())?; std::fs::remove_file(root.join("boards/tech/media/src/example.txt")) .context("remove declared fixture file")?; @@ -2440,8 +2474,9 @@ mod tests { board_file_fixtures(), Some(database_snapshot_fixture()?), 1_715_000_250_i64, - )?; - convert_fixture_to_split(&root, 16)?; + ) + .map(|_completed_value| ())?; + convert_fixture_to_split(&root, 16).map(|_completed_value| ())?; let verified = verify_saved_backup(&root, &[BackupScope::FullSite])?; ensure!(verified.metadata.storage_mode == BackupStorageMode::SplitZip); @@ -2513,7 +2548,7 @@ mod tests { #[test] fn verify_saved_backup_rejects_zip_backed_file_duplicate_at_root() -> TestResult<()> { let (_dir, root, _manifest) = saved_full_fixture("2026-05-06_full-site_duplicate-root")?; - convert_fixture_to_split(&root, 16)?; + convert_fixture_to_split(&root, 16).map(|_completed_value| ())?; let duplicate = root.join("boards/tech/media/src/example.txt"); let duplicate_parent = duplicate .parent() @@ -2643,7 +2678,7 @@ mod tests { fn verify_saved_backup_rejects_non_contiguous_split_part_index() -> TestResult<()> { let (_dir, root, _manifest) = saved_full_fixture("2026-05-06_full-site_part-gap")?; let mut manifest = convert_fixture_to_split(&root, 16)?; - manifest.parts.remove(0); + let _completed_value = manifest.parts.remove(0); let new_total = u32::try_from(manifest.parts.len()).context("part count exceeds u32")?; for part in &mut manifest.parts { part.total_parts = new_total; @@ -2676,7 +2711,9 @@ mod tests { .first_mut() .context("fixture manifest has no mutable split part")? .filename = replacement_filename.clone(); - std::fs::copy(original, root.join(replacement_filename)).context("copy split part")?; + std::fs::copy(original, root.join(replacement_filename)) + .context("copy split part") + .map(|_completed_value| ())?; rewrite_manifest(&root, &manifest)?; let error = verify_saved_backup(&root, &[BackupScope::FullSite]) @@ -2721,7 +2758,7 @@ mod tests { .context("start undeclared ZIP entry")?; zip.write_all(b"extra") .context("write undeclared ZIP entry")?; - zip.finish().context("finish split part")?; + drop(zip.finish().context("finish split part")?); let first_part = manifest .parts .first_mut() @@ -2756,7 +2793,7 @@ mod tests { zip.start_file("../escape.txt", zip::write::SimpleFileOptions::default()) .context("start unsafe ZIP entry")?; zip.write_all(b"escape").context("write unsafe ZIP entry")?; - zip.finish().context("finish split part")?; + drop(zip.finish().context("finish split part")?); let first_part = manifest .parts .first_mut() diff --git a/src/handlers/admin/content.rs b/src/handlers/admin/content.rs index 1133d415..a3a2e1fa 100644 --- a/src/handlers/admin/content.rs +++ b/src/handlers/admin/content.rs @@ -144,7 +144,8 @@ pub(in crate::server) async fn create_board( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; if db::get_board_by_short(&conn, &short)?.is_some() { return Err(AppError::Conflict(format!( "Board /{short}/ already exists." @@ -159,7 +160,8 @@ pub(in crate::server) async fn create_board( true, true, allow_audio, - )?; + ) + .map(|_completed_value| ())?; tracing::info!(target: "admin", board = %short, "Created board"); // Refresh live board list so the top bar on any subsequent error // page includes the newly created board. @@ -210,7 +212,7 @@ pub(in crate::server) async fn delete_board( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; // Fetch the board's short_name before deletion so we can remove // its upload directory entirely after cleaning tracked files. @@ -310,7 +312,8 @@ pub(in crate::server) async fn reorder_board( let pool = state.db.clone(); move || -> Result<()> { let mut conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; db::move_board(&mut conn, board_id, move_up)?; crate::templates::set_live_boards(db::get_all_boards(&conn)?); Ok(()) @@ -624,7 +627,7 @@ mod tests { use super::*; use anyhow::{bail, ensure, Context as _}; use axum::http::{header, StatusCode}; - use axum_extra::extract::cookie::{Cookie, CookieJar}; + use axum_extra::extract::cookie::Cookie; fn admin_signed_csrf() -> String { crate::utils::crypto::make_scoped_csrf_form_token( @@ -642,41 +645,41 @@ mod tests { fn admin_headers() -> axum::http::HeaderMap { let mut headers = axum::http::HeaderMap::new(); - headers.insert( + drop(headers.insert( header::HOST, axum::http::HeaderValue::from_static("localhost"), - ); - headers.insert( + )); + drop(headers.insert( header::ORIGIN, axum::http::HeaderValue::from_static("http://localhost"), - ); + )); headers } fn tunneled_admin_headers() -> axum::http::HeaderMap { let mut headers = axum::http::HeaderMap::new(); - headers.insert( + drop(headers.insert( header::HOST, axum::http::HeaderValue::from_static("demo.serveo.net"), - ); - headers.insert(header::ORIGIN, axum::http::HeaderValue::from_static("null")); - headers.insert( + )); + drop(headers.insert(header::ORIGIN, axum::http::HeaderValue::from_static("null"))); + drop(headers.insert( header::REFERER, axum::http::HeaderValue::from_static("https://demo.serveo.net/test/thread/1"), - ); + )); headers } fn cross_origin_headers() -> axum::http::HeaderMap { let mut headers = axum::http::HeaderMap::new(); - headers.insert( + drop(headers.insert( header::HOST, axum::http::HeaderValue::from_static("demo.serveo.net"), - ); - headers.insert( + )); + drop(headers.insert( header::ORIGIN, axum::http::HeaderValue::from_static("https://attacker.test"), - ); + )); headers } @@ -690,10 +693,12 @@ mod tests { &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create admin session")?; - db::create_board(&conn, "test", "Test", "", false).context("create board")?; + db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; let board = db::get_board_by_short(&conn, "test") .context("load board")? .context("test board does not exist")?; diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index 2637288d..acae7a7f 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -283,9 +283,9 @@ pub(super) fn ensure_admin_csrf(jar: CookieJar, secure: bool) -> Result<(CookieJ .map(str::to_owned); let mut jar = jar; let raw = raw.unwrap_or_else(|| { - let raw = new_csrf_token(); - jar = std::mem::take(&mut jar).add(admin_csrf_cookie(raw.clone(), secure)); - raw + let generated_token = new_csrf_token(); + jar = std::mem::take(&mut jar).add(admin_csrf_cookie(generated_token.clone(), secure)); + generated_token }); let session_id = jar .get(SESSION_COOKIE) @@ -1134,15 +1134,23 @@ fn fmt_epoch(timestamp: i64) -> String { fn db_integrity_status(status: &crate::middleware::DbMaintenanceJobStatus) -> String { match status { - crate::middleware::DbMaintenanceJobStatus::Finished { report, .. } => { + crate::middleware::DbMaintenanceJobStatus::Finished { report, job_id: _ } => { if report.after.as_ref().unwrap_or(&report.before).ok() { "passed at last check".to_owned() } else { "failed at last check".to_owned() } } - crate::middleware::DbMaintenanceJobStatus::Running { .. } => "check running".to_owned(), - crate::middleware::DbMaintenanceJobStatus::Failed { .. } => "last check failed".to_owned(), + crate::middleware::DbMaintenanceJobStatus::Running { + job_id: _, + started_at: _, + phase: _, + } => "check running".to_owned(), + crate::middleware::DbMaintenanceJobStatus::Failed { + job_id: _, + finished_at: _, + message: _, + } => "last check failed".to_owned(), crate::middleware::DbMaintenanceJobStatus::Idle => "not checked".to_owned(), } } @@ -1197,16 +1205,16 @@ fn safe_dir_size(root: &Path) -> Option { }; for entry in entries.flatten() { let entry_path = entry.path(); - let Ok(metadata) = std::fs::symlink_metadata(&entry_path) else { + let Ok(entry_metadata) = std::fs::symlink_metadata(&entry_path) else { continue; }; - if metadata.file_type().is_symlink() { + if entry_metadata.file_type().is_symlink() { continue; } - if metadata.is_dir() { + if entry_metadata.is_dir() { pending.push_back(entry_path); - } else if metadata.is_file() { - total = total.saturating_add(metadata.len()); + } else if entry_metadata.is_file() { + total = total.saturating_add(entry_metadata.len()); } } } @@ -1825,7 +1833,7 @@ fn render_runtime_workspaces(snapshot: &AdminPanelSnapshot, csrf_token: &str) -> .iter() .map(|section| { let fields = crate::config::admin::runtime::section_snapshot(*section) - .map_err(|_| "unavailable".to_owned()); + .map_err(|snapshot_error| { tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); "unavailable".to_owned() }); crate::templates::admin::render_runtime_settings(*section, &fields, csrf_token) }) .collect(); @@ -1834,7 +1842,10 @@ fn render_runtime_workspaces(snapshot: &AdminPanelSnapshot, csrf_token: &str) -> "{runtime_html}{}{}{application_html}", crate::templates::admin::render_accounts(&snapshot.accounts, csrf_token), crate::templates::admin::render_management( - &crate::config::admin::management::snapshot().map_err(|_| "unavailable".to_owned()), + &crate::config::admin::management::snapshot().map_err(|snapshot_error| { + tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); + "unavailable".to_owned() + }), csrf_token ) ) @@ -1854,8 +1865,10 @@ fn render_admin_panel_from_snapshot( is_error: *is_error, message, }); - let network_fields = - crate::config::admin::network_snapshot().map_err(|_| "unavailable".to_owned()); + let network_fields = crate::config::admin::network_snapshot().map_err(|snapshot_error| { + tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); + "unavailable".to_owned() + }); let view = crate::templates::AdminPanelViewModel { network_html: crate::templates::admin::render_network_settings(&network_fields, csrf_token), runtime_html: render_runtime_workspaces(snapshot, csrf_token), @@ -2126,7 +2139,9 @@ pub(in crate::server) async fn admin_panel( } else { None }; - updates::authorize(&state, &jar).await?; + updates::authorize(&state, &jar) + .await + .map(|_completed_value| ())?; let update_status = updates::snapshot().await; let restart_view = restart::snapshot(Some(&update_status)).await; let auto_full_backup_settings = state.auto_full_backup_settings.snapshot(); @@ -2139,7 +2154,8 @@ pub(in crate::server) async fn admin_panel( // Auth check inside blocking task let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; db::get_session(&conn, &sid)? - .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; + .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into())) + .map(|_completed_value| ())?; let (snapshot, tor_address) = load_admin_panel_snapshot( &conn, @@ -2178,7 +2194,7 @@ pub(in crate::server) async fn admin_site_health_jobs( let state = state.clone(); move || -> Result { let conn = state.db.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(load_site_health_jobs_snapshot(&conn, &state)) } }) @@ -2267,7 +2283,7 @@ pub(in crate::server) async fn admin_live_log( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let logs_dir = crate::config::logs_dir(); @@ -2360,7 +2376,8 @@ fn admin_bootstrap_now_secs() -> u64 { pub(super) fn create_admin_session_bootstrap(session_id: &str) -> String { let token = crate::utils::crypto::new_session_id(); let expires_at = admin_bootstrap_now_secs().saturating_add(ADMIN_BOOTSTRAP_TTL_SECS); - ADMIN_SESSION_BOOTSTRAPS.insert(token.clone(), (session_id.to_owned(), expires_at)); + let _previous_value = + ADMIN_SESSION_BOOTSTRAPS.insert(token.clone(), (session_id.to_owned(), expires_at)); token } @@ -2449,7 +2466,10 @@ fn build_application_state(snapshot: &AdminPanelSnapshot) -> String { &live, snapshot.saved_ffmpeg_timeout.as_deref(), ) - .map_err(|_| "unavailable".to_owned()), + .map_err(|snapshot_error| { + tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); + "unavailable".to_owned() + }), ) } @@ -2495,7 +2515,7 @@ mod tests { cookie_header.push_str(raw); } let mut headers = HeaderMap::new(); - headers.insert(header::COOKIE, HeaderValue::from_str(&cookie_header)?); + drop(headers.insert(header::COOKIE, HeaderValue::from_str(&cookie_header)?)); let (jar, token) = ensure_admin_csrf(CookieJar::from_headers(&headers), secure)?; let raw = jar.get("csrf_token").context("CSRF cookie is missing")?; ensure!(!raw.value().is_empty(), "CSRF cookie must not be empty"); @@ -2573,7 +2593,8 @@ mod tests { VALUES (?1, 'active', ?2, ?2, 0)", rusqlite::params![board_id, now], ) - .context("insert active thread")?; + .context("insert active thread") + .map(|_completed_value| ())?; let active_thread_id = conn.last_insert_rowid(); conn.execute( "INSERT INTO posts @@ -2586,7 +2607,8 @@ mod tests { 7, 'audio/ogg', ?3, 'delete-active', 1)", rusqlite::params![active_thread_id, board_id, now], ) - .context("insert active post")?; + .context("insert active post") + .map(|_completed_value| ())?; let active_post_id = conn.last_insert_rowid(); conn.execute( @@ -2594,7 +2616,8 @@ mod tests { VALUES (?1, 'archived', ?2, ?2, 1)", rusqlite::params![board_id, now - (8 * 24 * 60 * 60)], ) - .context("insert archived thread")?; + .context("insert archived thread") + .map(|_completed_value| ())?; let archived_thread_id = conn.last_insert_rowid(); conn.execute( "INSERT INTO posts @@ -2605,14 +2628,16 @@ mod tests { 1000, 'dash/video-thumb.webp', 'video/webm', 'video', ?3, 'delete-archived', 1)", rusqlite::params![archived_thread_id, board_id, now - (8 * 24 * 60 * 60)], ) - .context("insert archived post")?; + .context("insert archived post") + .map(|_completed_value| ())?; conn.execute( "INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash, created_at) VALUES (?1, ?2, ?3, 'needs review', 'reporter', ?4)", rusqlite::params![active_post_id, active_thread_id, board_id, now], ) - .context("insert report")?; + .context("insert report") + .map(|_completed_value| ())?; let activity = load_dashboard_activity_snapshot(&conn, 1); @@ -2821,7 +2846,7 @@ mod tests { fn same_origin_headers(host: &str) -> HeaderMap { let mut headers = HeaderMap::new(); if let Ok(host) = HeaderValue::from_str(host) { - headers.insert(header::HOST, host); + drop(headers.insert(header::HOST, host)); } headers } @@ -2853,44 +2878,44 @@ mod tests { #[test] fn same_origin_request_accepts_loopback_aliases_with_matching_port() { let mut headers = same_origin_headers("127.0.0.1:8080"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("http://localhost:8080"), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); - let mut headers = same_origin_headers("[::1]:8080"); - headers.insert( + let mut ipv6_headers = same_origin_headers("[::1]:8080"); + drop(ipv6_headers.insert( header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:8080"), - ); - assert!(require_same_origin_request(&headers, None).is_ok()); + )); + assert!(require_same_origin_request(&ipv6_headers, None).is_ok()); } #[test] fn same_origin_request_accepts_ipv6_loopback_bracket_format() { let mut headers = same_origin_headers("[::1]:8080"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("http://[::1]:8080"), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); } #[test] fn same_origin_request_accepts_referer_when_origin_is_missing() { let mut headers = same_origin_headers("localhost:8080"); - headers.insert( + drop(headers.insert( header::REFERER, HeaderValue::from_static("http://127.0.0.1:8080/admin"), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); } #[test] fn same_origin_request_accepts_valid_onion_http_origin() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN))); assert!(require_same_origin_request(&headers, None).is_ok()); } @@ -2898,13 +2923,13 @@ mod tests { #[test] fn same_origin_request_accepts_valid_onion_http_referer() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert( + drop(headers.insert( header::REFERER, HeaderValue::from_static(concat!( "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion", "/admin/panel" )), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); } @@ -2912,12 +2937,12 @@ mod tests { #[test] fn same_origin_request_rejects_spoofed_onion_origin() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static( "http://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbm2dqd.onion", ), - ); + )); assert!(require_same_origin_request(&headers, None).is_err()); } @@ -2925,7 +2950,7 @@ mod tests { #[test] fn same_origin_request_keeps_onion_http_when_tls_uses_default_https_port() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN))); assert_eq!( request_scheme_for_same_origin_with_config( @@ -2961,14 +2986,14 @@ mod tests { #[test] fn same_origin_request_accepts_missing_origin_and_referer_with_same_origin_fetch_metadata() { let mut headers = same_origin_headers("demo.serveo.net"); - headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin")); + drop(headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"))); assert!(require_same_origin_request(&headers, None).is_ok()); } #[test] fn same_origin_request_rejects_missing_origin_and_referer_with_cross_site_fetch_metadata() { let mut headers = same_origin_headers("demo.serveo.net"); - headers.insert("sec-fetch-site", HeaderValue::from_static("cross-site")); + drop(headers.insert("sec-fetch-site", HeaderValue::from_static("cross-site"))); assert!(require_same_origin_request(&headers, None).is_err()); } @@ -2987,41 +3012,41 @@ mod tests { #[test] fn same_origin_or_valid_csrf_rejects_cross_origin_post_with_valid_csrf() { let mut headers = same_origin_headers("demo.serveo.net"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://evil.test"), - ); + )); assert!(require_same_origin_or_valid_csrf(&headers, None, true).is_err()); } #[test] fn same_origin_request_accepts_null_origin_with_same_origin_referer_on_https_tunnel() { let mut headers = same_origin_headers("demo.serveo.net"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); - headers.insert( + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); + drop(headers.insert( header::REFERER, HeaderValue::from_static("https://demo.serveo.net/admin"), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); } #[test] fn same_origin_request_accepts_same_host_https_origin_on_https_tunnel() { let mut headers = same_origin_headers("rustchan.serveousercontent.com"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://rustchan.serveousercontent.com"), - ); + )); assert!(require_same_origin_request(&headers, None).is_ok()); } #[test] fn admin_post_csrf_accepts_scoped_token_on_https_tunnel_host() { let mut headers = same_origin_headers("rustchan.serveousercontent.com"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://rustchan.serveousercontent.com"), - ); + )); let token = crate::utils::crypto::make_scoped_csrf_form_token( "csrf123", &crate::config::CONFIG.cookie_secret, @@ -3043,31 +3068,31 @@ mod tests { #[test] fn same_origin_request_rejects_null_origin_for_non_loopback_targets() { let mut headers = same_origin_headers("192.168.1.20:8080"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); assert!(require_same_origin_request(&headers, None).is_err()); - let mut headers = same_origin_headers("board-admin-exampleonion123.onion"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); - assert!(require_same_origin_request(&headers, None).is_err()); + let mut onion_headers = same_origin_headers("board-admin-exampleonion123.onion"); + drop(onion_headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); + assert!(require_same_origin_request(&onion_headers, None).is_err()); } #[test] fn same_origin_request_rejects_default_https_origin_with_explicit_http_port() { let mut headers = same_origin_headers("example.test:8080"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://example.test"), - ); + )); assert!(require_same_origin_request(&headers, None).is_err()); } #[test] fn same_origin_request_rejects_port_mismatch_even_for_loopback_aliases() { let mut headers = same_origin_headers("localhost:8080"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:3000"), - ); + )); assert!(require_same_origin_request(&headers, None).is_err()); } @@ -3112,43 +3137,43 @@ mod tests { #[test] fn same_origin_request_rejects_userinfo_bypass_shapes() { let mut headers = same_origin_headers("127.0.0.1:8080"); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("http://127.0.0.1@evil.com:8080"), - ); + )); assert!(require_same_origin_request(&headers, None).is_err()); - let mut headers = same_origin_headers("127.0.0.1:8080"); - headers.insert( + let mut userinfo_headers = same_origin_headers("127.0.0.1:8080"); + drop(userinfo_headers.insert( header::ORIGIN, HeaderValue::from_static("http://evil.com@127.0.0.1:8080"), - ); - assert!(require_same_origin_request(&headers, None).is_err()); + )); + assert!(require_same_origin_request(&userinfo_headers, None).is_err()); } #[test] fn same_origin_request_rejects_non_loopback_null_origin_lookalikes() { let mut headers = same_origin_headers("localhost.evil.com:8080"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); assert!(require_same_origin_request(&headers, None).is_err()); - let mut headers = same_origin_headers("192.168.1.20:8080"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); - assert!(require_same_origin_request(&headers, None).is_err()); + let mut lan_headers = same_origin_headers("192.168.1.20:8080"); + drop(lan_headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); + assert!(require_same_origin_request(&lan_headers, None).is_err()); - let mut headers = same_origin_headers("examplehiddenservice.onion"); - headers.insert(header::ORIGIN, HeaderValue::from_static("null")); - assert!(require_same_origin_request(&headers, None).is_err()); + let mut onion_headers = same_origin_headers("examplehiddenservice.onion"); + drop(onion_headers.insert(header::ORIGIN, HeaderValue::from_static("null"))); + assert!(require_same_origin_request(&onion_headers, None).is_err()); } #[test] fn https_host_port_marks_request_secure() -> anyhow::Result<()> { let mut headers = HeaderMap::new(); let host = format!("example.test:{}", crate::config::CONFIG.tls.port); - headers.insert( + drop(headers.insert( header::HOST, HeaderValue::from_str(&host).context("build HTTPS host header")?, - ); + )); ensure!(host_header_uses_https_port_with_config( &headers, crate::config::CONFIG.tls.port @@ -3159,7 +3184,7 @@ mod tests { #[test] fn http_host_port_does_not_mark_request_secure() { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("example.test:8080")); + drop(headers.insert(header::HOST, HeaderValue::from_static("example.test:8080"))); assert!(!host_header_uses_https_port_with_config( &headers, crate::config::CONFIG.tls.port @@ -3169,33 +3194,33 @@ mod tests { #[test] fn https_origin_marks_tunneled_request_secure() { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("demo.serveo.net")); - headers.insert( + drop(headers.insert(header::HOST, HeaderValue::from_static("demo.serveo.net"))); + drop(headers.insert( header::REFERER, HeaderValue::from_static("https://demo.serveo.net/admin"), - ); + )); assert!(request_origin_uses_https(&headers)); } #[test] fn mismatched_https_origin_does_not_mark_request_secure() { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("demo.serveo.net")); - headers.insert( + drop(headers.insert(header::HOST, HeaderValue::from_static("demo.serveo.net"))); + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://evil.example"), - ); + )); assert!(!request_origin_uses_https(&headers)); } #[test] fn secure_cookie_decision_ignores_spoofed_https_origin_on_plain_http() { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("localhost:8080")); - headers.insert( + drop(headers.insert(header::HOST, HeaderValue::from_static("localhost:8080"))); + drop(headers.insert( header::ORIGIN, HeaderValue::from_static("https://localhost:8080"), - ); + )); let context = SecureCookieContext::new( Some(std::net::SocketAddr::from(([127, 0, 0, 1], 41_000))), false, @@ -3211,10 +3236,10 @@ mod tests { { let mut headers = HeaderMap::new(); let host = format!("example.test:{}", crate::config::CONFIG.tls.port); - headers.insert( + drop(headers.insert( header::HOST, HeaderValue::from_str(&host).context("build HTTPS-port host header")?, - ); + )); let context = SecureCookieContext::new( Some(std::net::SocketAddr::from(([127, 0, 0, 1], 41_000))), false, @@ -3229,7 +3254,7 @@ mod tests { #[test] fn secure_cookie_decision_keeps_onion_plain_http_cookie_insecure() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN))); let context = SecureCookieContext::new( Some(std::net::SocketAddr::from(([127, 0, 0, 1], 41_000))), false, @@ -3243,10 +3268,10 @@ mod tests { #[test] fn secure_cookie_decision_marks_direct_onion_https_cookie_secure() { let mut headers = same_origin_headers(TEST_ONION_HOST); - headers.insert( + drop(headers.insert( header::ORIGIN, HeaderValue::from_static(TEST_ONION_HTTPS_ORIGIN), - ); + )); let context = SecureCookieContext::new( Some(std::net::SocketAddr::from(([127, 0, 0, 1], 41_000))), true, @@ -3260,8 +3285,8 @@ mod tests { #[test] fn secure_cookie_decision_requires_trusted_proxy_for_forwarded_proto() { let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("localhost:8080")); - headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + drop(headers.insert(header::HOST, HeaderValue::from_static("localhost:8080"))); + drop(headers.insert("x-forwarded-proto", HeaderValue::from_static("https"))); let trusted = SecureCookieContext::new( Some(std::net::SocketAddr::from(([127, 0, 0, 1], 41_000))), false, @@ -3382,7 +3407,7 @@ mod tests { &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create admin session")?; Ok(()) @@ -3430,7 +3455,8 @@ mod tests { "INSERT INTO threads (board_id, subject) VALUES (?1, 'job thread')", rusqlite::params![board_id], ) - .context("insert job thread")?; + .context("insert job thread") + .map(|_completed_value| ())?; let thread_id = conn.last_insert_rowid(); conn.execute( "INSERT INTO posts @@ -3438,7 +3464,8 @@ mod tests { VALUES (?1, ?2, 'job body', 'job body', 'delete-token', 1)", rusqlite::params![thread_id, board_id], ) - .context("insert job post")?; + .context("insert job post") + .map(|_completed_value| ())?; let post_id = conn.last_insert_rowid(); expected_post_id = post_id; expected_post_url = format!("/test/thread/{thread_id}#p{post_id}"); @@ -3447,7 +3474,7 @@ mod tests { "d": { "post_id": post_id, "ip_hash": "hash", - "body_len": 8 + "body_len": 8_i32 } }) .to_string(); @@ -3462,7 +3489,8 @@ mod tests { "failed reading /Users/example/private.txt with token=abc123 ".repeat(8) ], ) - .context("insert background jobs")?; + .context("insert background jobs") + .map(|_completed_value| ())?; } let response = admin_site_health_jobs( State(state), @@ -3507,7 +3535,7 @@ mod tests { .and_then(|jobs| jobs.first()) .context("site-health payload omitted failed job detail")?; ensure!(failed_job["name"] == "Spam check"); - ensure!(failed_job["attempts"] == 3); + ensure!(failed_job["attempts"] == 3_i32); ensure!(failed_job["post_id"] == expected_post_id); ensure!(failed_job["post_url"] == expected_post_url); let error = failed_job["error"] @@ -3534,7 +3562,8 @@ mod tests { ('video_transcode', '{}', 'failed', 3, 'ffmpeg failed', unixepoch())", [], ) - .context("insert failed job")?; + .context("insert failed job") + .map(|_completed_value| ())?; ensure!( crate::db::background_job_summary(&conn) .context("load summary before dismiss")? @@ -3543,7 +3572,7 @@ mod tests { ); } let mut headers = same_origin_headers("localhost"); - headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost")); + drop(headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost"))); let response = dismiss_failed_site_health_jobs( State(state.clone()), CookieJar::new() diff --git a/src/handlers/admin/moderation.rs b/src/handlers/admin/moderation.rs index c7f502b1..e61f7533 100644 --- a/src/handlers/admin/moderation.rs +++ b/src/handlers/admin/moderation.rs @@ -40,7 +40,11 @@ pub(in crate::server) async fn add_ban( .filter(|&h| h > 0) // Cap at 87_600 hours (10 years) to prevent overflow in h * 3600. // Permanent bans are represented by None (duration_hours absent or zero). - .map(|h| Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + .map(|h| { + Utc::now() + .timestamp() + .saturating_add(h.min(87_600).saturating_mul(3600)) + }); let ip_hash_log = form.ip_hash.chars().take(8).collect::(); @@ -50,7 +54,8 @@ pub(in crate::server) async fn add_ban( let conn = pool.get()?; let (admin_id, admin_name) = super::require_admin_session_with_name(&conn, session_id.as_deref())?; - db::add_ban(&conn, &form.ip_hash, &form.reason, expires_at)?; + db::add_ban(&conn, &form.ip_hash, &form.reason, expires_at) + .map(|_completed_value| ())?; if let Err(error) = db::log_mod_action( &conn, admin_id, @@ -101,7 +106,8 @@ pub(in crate::server) async fn remove_ban( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; db::remove_ban(&conn, form.ban_id)?; Ok(()) } @@ -155,10 +161,11 @@ pub(in crate::server) async fn admin_ban_and_delete( .filter(|r| !r.is_empty()) .unwrap_or_else(|| "Rule violation".to_owned()); - let expires_at = form - .duration_hours - .filter(|&h| h > 0) - .map(|h| Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + let expires_at = form.duration_hours.filter(|&h| h > 0).map(|h| { + Utc::now() + .timestamp() + .saturating_add(h.min(87_600).saturating_mul(3600)) + }); let ip_hash_log = form.ip_hash.chars().take(8).collect::(); let post_id = form.post_id; @@ -187,7 +194,7 @@ pub(in crate::server) async fn admin_ban_and_delete( } // Ban first so the IP cannot re-post before the delete lands - db::add_ban(&conn, &form.ip_hash, &reason, expires_at)?; + db::add_ban(&conn, &form.ip_hash, &reason, expires_at).map(|_completed_value| ())?; if let Err(error) = db::log_mod_action( &conn, admin_id, @@ -326,7 +333,8 @@ pub(in crate::server) async fn dismiss_appeal( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; db::dismiss_ban_appeal(&conn, form.appeal_id)?; Ok(()) } @@ -429,8 +437,9 @@ pub(in crate::server) async fn add_filter( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; - db::add_word_filter(&conn, &pattern, &replacement)?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; + db::add_word_filter(&conn, &pattern, &replacement).map(|_completed_value| ())?; Ok(()) } }) @@ -462,7 +471,8 @@ pub(in crate::server) async fn remove_filter( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; db::remove_word_filter(&conn, form.filter_id)?; Ok(()) } @@ -520,7 +530,8 @@ pub(in crate::server) async fn admin_ip_history( move || -> Result { const PER_PAGE: i64 = 25; let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; let total = db::count_posts_by_ip_hash(&conn, &ip_hash)?; let pagination = crate::models::Pagination::new(page, PER_PAGE, total); @@ -726,8 +737,7 @@ mod tests { use super::*; use crate::test_support::admin_signed_csrf; use anyhow::{ensure, Context as _}; - use axum::extract::State; - use axum_extra::extract::cookie::{Cookie, CookieJar}; + use axum_extra::extract::cookie::Cookie; fn build_admin_jar() -> CookieJar { CookieJar::new() @@ -737,14 +747,14 @@ mod tests { fn admin_headers() -> axum::http::HeaderMap { let mut headers = axum::http::HeaderMap::new(); - headers.insert( + drop(headers.insert( axum::http::header::HOST, axum::http::HeaderValue::from_static("localhost"), - ); - headers.insert( + )); + drop(headers.insert( axum::http::header::ORIGIN, axum::http::HeaderValue::from_static("http://localhost"), - ); + )); headers } @@ -823,7 +833,9 @@ mod tests { db::create_admin(&conn, "admin", &password_hash).context("create test admin")?; db::create_session(&conn, "session123", admin_id, Utc::now().timestamp() + 3600) .context("create test admin session")?; - db::create_board(&conn, "test", "Test", "", false).context("create test board")?; + db::create_board(&conn, "test", "Test", "", false) + .context("create test board") + .map(|_completed_value| ())?; let board = db::get_board_by_short(&conn, "test") .context("load test board")? .context("test board was not persisted")?; @@ -868,8 +880,8 @@ mod tests { "administrator report redirect did not target the reports anchor" ); - let conn = state.db.get().context("get verification connection")?; - let (reporter_hash, reason): (String, String) = conn + let verification_conn = state.db.get().context("get verification connection")?; + let (reporter_hash, reason): (String, String) = verification_conn .query_row( "SELECT reporter_hash, reason FROM reports WHERE post_id = ?1", rusqlite::params![reply_id], @@ -929,7 +941,8 @@ pub(in crate::server) async fn mod_log_page( move || -> Result { const PER_PAGE: i64 = 50; let conn = pool.get()?; - super::require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; let total = db::count_mod_log(&conn)?; let pagination = crate::models::Pagination::new(page, PER_PAGE, total); diff --git a/src/handlers/admin/restart.rs b/src/handlers/admin/restart.rs index 2fbfa967..0643f4fc 100644 --- a/src/handlers/admin/restart.rs +++ b/src/handlers/admin/restart.rs @@ -74,7 +74,9 @@ pub(in crate::server) async fn status( State(state): State, jar: CookieJar, ) -> Result { - updates::authorize(&state, &jar).await?; + updates::authorize(&state, &jar) + .await + .map(|_completed_value| ())?; let status = updates::snapshot().await; Ok(Json(snapshot(Some(&status)).await).into_response()) } @@ -107,7 +109,7 @@ pub(in crate::server) async fn restart( let acknowledged_rejection = result.as_ref().is_ok_and(|reply| reply.error.is_some()); if !acknowledged_rejection { let cancel = state.job_queue.cancel.clone(); - tokio::spawn(async move { + drop(tokio::spawn(async move { let _guard = guard; loop { if cancel.is_cancelled() { @@ -123,7 +125,7 @@ pub(in crate::server) async fn restart( } tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - }); + })); } match result { Ok(reply) if reply.error.is_none() => {}, @@ -135,14 +137,14 @@ pub(in crate::server) async fn restart( .await .map_err(|error| AppError::Internal(error.into()))??; let cancel = state.job_queue.cancel.clone(); - tokio::spawn(async move { + drop(tokio::spawn(async move { let _guard = guard; // Permit the 303 response to reach the browser before closing listeners. tokio::time::sleep(std::time::Duration::from_secs(1)).await; cancel.cancel(); cancel.cancelled().await; tokio::time::sleep(crate::restart::SHUTDOWN_TIMEOUT).await; - }); + })); } else { return Err(AppError::BadRequest( "This deployment has no configured restart supervisor.".into(), @@ -195,10 +197,10 @@ mod tests { #[tokio::test] async fn enhanced_acknowledgment_preserves_acceptance_and_rejection() -> anyhow::Result<()> { let mut headers = HeaderMap::new(); - headers.insert( + drop(headers.insert( "accept", axum::http::HeaderValue::from_static("application/json"), - ); + )); for (accepted, expected) in [ (Some(true), StatusCode::ACCEPTED), (None, StatusCode::ACCEPTED), @@ -285,11 +287,11 @@ mod tests { session, )); let mut headers = HeaderMap::new(); - headers.insert("host", axum::http::HeaderValue::from_static("localhost")); - headers.insert( + drop(headers.insert("host", axum::http::HeaderValue::from_static("localhost"))); + drop(headers.insert( "origin", axum::http::HeaderValue::from_static("http://localhost"), - ); + )); let result = restart( State(state), jar, diff --git a/src/handlers/admin/settings.rs b/src/handlers/admin/settings.rs index 0772a10e..f5c5b931 100644 --- a/src/handlers/admin/settings.rs +++ b/src/handlers/admin/settings.rs @@ -134,7 +134,7 @@ pub(in crate::server) async fn update_visitor_defaults( let session = jar.get(SESSION_COOKIE).map(|c| c.value().to_owned()); tokio::task::spawn_blocking(move || -> Result<()> { let conn = state.db.get()?; - require_admin_session_sid(&conn, session.as_deref())?; + require_admin_session_sid(&conn, session.as_deref()).map(|_completed_value| ())?; db::set_site_setting( &conn, "default_hide_nsfw_boards", diff --git a/src/handlers/admin/settings/appearance.rs b/src/handlers/admin/settings/appearance.rs index f68597b0..6506ac35 100644 --- a/src/handlers/admin/settings/appearance.rs +++ b/src/handlers/admin/settings/appearance.rs @@ -30,7 +30,7 @@ pub(in crate::server) async fn clear_board_favicon_override( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let board_short: String = conn.query_row( "SELECT short_name FROM boards WHERE id = ?1", rusqlite::params![form.board_id], @@ -92,7 +92,7 @@ pub(in crate::server) async fn update_site_favicon( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; crate::favicon::write_favicon_set( crate::favicon::FaviconScope::Global, &favicon_bytes, @@ -162,7 +162,7 @@ pub(in crate::server) async fn update_board_favicon( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let board_short: String = conn.query_row( "SELECT short_name FROM boards WHERE id = ?1", rusqlite::params![board_id], diff --git a/src/handlers/admin/settings/backup_settings.rs b/src/handlers/admin/settings/backup_settings.rs index c5c49fbf..e0d03030 100644 --- a/src/handlers/admin/settings/backup_settings.rs +++ b/src/handlers/admin/settings/backup_settings.rs @@ -52,18 +52,19 @@ fn parse_full_backup_settings_form( let storage_mode_value = match storage_mode { crate::handlers::admin::BackupStorageMode::Directory => "directory", crate::handlers::admin::BackupStorageMode::SplitZip => "split_zip", - _ => { + crate::handlers::admin::BackupStorageMode::SingleZip + | crate::handlers::admin::BackupStorageMode::LegacyZip => { return Err(AppError::BadRequest( "Unsupported automatic backup storage mode.".into(), )); } }; - let split_zip_part_size_gib = form + let requested_part_size_gib = form .auto_full_backup_split_zip_part_size_gib .as_deref() .and_then(|value| value.parse::().ok()); let split_zip_part_size = - crate::handlers::admin::backup::parse_split_zip_part_size_gib(split_zip_part_size_gib)?; + crate::handlers::admin::backup::parse_split_zip_part_size_gib(requested_part_size_gib)?; let split_zip_part_size_gib = crate::handlers::admin::backup::split_zip_part_size_gib(split_zip_part_size); @@ -90,7 +91,7 @@ pub(in crate::server) async fn update_full_backup_settings( if let Some(directory) = form.backup_directory { tokio::task::spawn_blocking(move || -> Result<()> { let conn = state.db.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; crate::config::update_settings_file_backup_directory(std::path::Path::new(&directory)) .map_err(|error| AppError::BadRequest(format!("{error:#}")))?; Ok(()) @@ -116,7 +117,7 @@ pub(in crate::server) async fn update_full_backup_settings( let auto_backup_settings = state.auto_full_backup_settings.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; crate::config::update_settings_file_auto_full_backup( interval_hours, copies_to_keep, diff --git a/src/handlers/admin/settings/banners.rs b/src/handlers/admin/settings/banners.rs index b3e9e754..86ccc6ec 100644 --- a/src/handlers/admin/settings/banners.rs +++ b/src/handlers/admin/settings/banners.rs @@ -174,7 +174,8 @@ fn restore_board_banner_inheritance_if_empty( conn.execute( "UPDATE boards SET banner_mode = 'inherit' WHERE id = ?1 AND banner_mode = 'override'", rusqlite::params![board_id], - )?; + ) + .map(|_completed_value| ())?; } Ok(()) } @@ -211,7 +212,7 @@ fn delete_banner_asset_safely( let tx = conn.unchecked_transaction()?; let asset = db::get_banner_asset(&tx, banner_id)? .ok_or_else(|| AppError::BadRequest("Banner not found.".into()))?; - db::delete_banner_asset(&tx, banner_id)?; + db::delete_banner_asset(&tx, banner_id).map(|_completed_value| ())?; if asset.scope == BannerScope::Board { restore_board_banner_inheritance_if_empty(&tx, asset.board_id)?; } @@ -240,11 +241,12 @@ fn clear_board_banner_assets_safely( let tx = conn.unchecked_transaction()?; let assets = db::list_banner_assets_for_board(&tx, board_id)?; let pending_op = banner_cleanup_payload(&assets)?; - db::delete_board_banner_assets(&tx, board_id)?; + db::delete_board_banner_assets(&tx, board_id).map(|_completed_value| ())?; tx.execute( "UPDATE boards SET banner_mode = 'inherit' WHERE id = ?1 AND banner_mode = 'override'", rusqlite::params![board_id], - )?; + ) + .map(|_completed_value| ())?; if let Some(op) = pending_op.as_ref() { db::insert_pending_fs_op(&tx, op)?; } @@ -270,7 +272,7 @@ async fn upload_banner_for_scope( ) -> Result { tokio::task::spawn_blocking(move || -> Result { let mut conn = state.db.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let (target_type, target_value) = resolve_banner_target_selection( &parsed.target_type, parsed.target_value.as_deref(), @@ -508,7 +510,7 @@ pub(in crate::server) async fn update_banner_meta( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let asset = db::get_banner_asset(&conn, form.banner_id)? .ok_or_else(|| AppError::BadRequest("Banner not found.".into()))?; let (target_type, target_value) = resolve_banner_target_selection( @@ -576,7 +578,7 @@ pub(in crate::server) async fn delete_banner( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let asset = delete_banner_asset_safely(&conn, form.banner_id)?; Ok(banner::banner_admin_anchor( asset.scope, @@ -618,7 +620,7 @@ pub(in crate::server) async fn move_banner( let pool = state.db.clone(); move || -> Result { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let asset = db::get_banner_asset(&conn, form.banner_id)? .ok_or_else(|| AppError::BadRequest("Banner not found.".into()))?; db::move_banner_asset(&mut conn, form.banner_id, move_up)?; @@ -653,7 +655,7 @@ pub(in crate::server) async fn clear_board_banner_override( let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let (board_short, _assets) = clear_board_banner_assets_safely(&conn, form.board_id)?; Ok(board_short) } @@ -695,7 +697,8 @@ mod tests { "UPDATE boards SET banner_mode = 'override' WHERE id = ?1", rusqlite::params![board_id], ) - .context("set override mode")?; + .context("set override mode") + .map(|_completed_value| ())?; restore_board_banner_inheritance_if_empty(&conn, Some(board_id))?; @@ -713,7 +716,8 @@ mod tests { "UPDATE boards SET banner_mode = 'override' WHERE id = ?1", rusqlite::params![board_id], ) - .context("set override mode")?; + .context("set override mode") + .map(|_completed_value| ())?; crate::db::insert_banner_asset( &conn, crate::models::BannerScope::Board, @@ -729,7 +733,8 @@ mod tests { true, true, ) - .context("insert board banner")?; + .context("insert board banner") + .map(|_completed_value| ())?; restore_board_banner_inheritance_if_empty(&conn, Some(board_id))?; diff --git a/src/handlers/admin/settings/board.rs b/src/handlers/admin/settings/board.rs index 5db57a9c..520d2684 100644 --- a/src/handlers/admin/settings/board.rs +++ b/src/handlers/admin/settings/board.rs @@ -164,7 +164,7 @@ pub(in crate::server) async fn update_board_settings( let pool = state.db.clone(); move || -> Result { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let board_short: String = conn.query_row( "SELECT short_name FROM boards WHERE id = ?1", rusqlite::params![board_id], diff --git a/src/handlers/admin/settings/maintenance.rs b/src/handlers/admin/settings/maintenance.rs index 303fe678..80970568 100644 --- a/src/handlers/admin/settings/maintenance.rs +++ b/src/handlers/admin/settings/maintenance.rs @@ -62,6 +62,69 @@ fn create_pre_repair_backup( ) } +/// Run repair under the caller's maintenance guard, retaining stale-job diagnostics. +fn run_db_repair_job( + pool: &db::DbPool, + progress: &std::sync::Arc, + jobs: &crate::middleware::DbMaintenanceJobs, + job_id: u64, +) { + if !jobs.mark_phase(job_id, crate::middleware::DbMaintenanceJobPhase::Backup) { + tracing::debug!(target: "admin", job_id, "Ignored backup phase update for stale database repair job"); + } + let backup_result = create_pre_repair_backup(pool, progress, job_id); + + let conn = match pool.get() { + Ok(conn) => conn, + Err(error) => { + if !jobs.mark_failed( + job_id, + format!("Could not open database connection after pre-repair backup: {error}"), + ) { + tracing::debug!(target: "admin", job_id, %error, "Ignored failure update for stale database repair job"); + } + return; + } + }; + + if !jobs.mark_phase(job_id, crate::middleware::DbMaintenanceJobPhase::Repair) { + tracing::debug!(target: "admin", job_id, "Ignored repair phase update for stale database repair job"); + } + let report = match backup_result { + Ok(backup_id) => db::attempt_db_repair( + &conn, + Some(db::DbRepairBackup { + backup_id: backup_id.clone(), + backup_type: "DB + config".to_owned(), + backup_path: crate::config::backups_dir() + .join(&backup_id) + .display() + .to_string(), + verified: true, + }), + ), + Err(error) => { + let backup_error = error.to_string(); + db::db_repair_aborted_for_backup_failure(&conn, &backup_error) + } + }; + tracing::info!( + target: "admin", + before_ok = report.before.ok(), + after_ok = report.after.as_ref().map(db::DbHealthSnapshot::ok), + steps = report.repair_steps.len(), + backup = report + .repair_backup + .as_ref() + .map(|backup| backup.backup_id.as_str()), + backup_error = report.repair_backup_error.as_deref(), + "Admin ran database repair attempt" + ); + if !jobs.mark_finished(job_id, report) { + tracing::debug!(target: "admin", job_id, "Ignored completion update for stale database repair job"); + } +} + fn parse_ffmpeg_timeout_secs_input(input: Option<&str>) -> Result { let raw = input .map(str::trim) @@ -155,7 +218,7 @@ pub(in crate::server) async fn update_media_settings( let pool = state.db.clone(); move || -> Result<()> { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let tx = conn.transaction().map_err(anyhow::Error::from)?; db::set_media_prune_settings(&tx, prune_enabled, prune_max_bytes)?; db::set_site_setting(&tx, "ffmpeg_timeout_secs", &timeout_secs.to_string())?; @@ -207,7 +270,7 @@ pub(in crate::server) async fn admin_vacuum( let csrf_clone = csrf.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let size_before = db::get_db_size_bytes(&conn).unwrap_or(0); @@ -259,7 +322,7 @@ pub(in crate::server) async fn admin_db_check( let csrf_clone = csrf.clone(); move || -> Result { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let report = db::check_db_health(&conn); tracing::info!( @@ -303,7 +366,7 @@ pub(in crate::server) async fn admin_db_repair( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(()) } }) @@ -318,67 +381,22 @@ pub(in crate::server) async fn admin_db_repair( let pool = state.db.clone(); let db_maintenance_jobs = state.db_maintenance_jobs.clone(); - tokio::spawn(async move { + drop(tokio::spawn(async move { let job_status = db_maintenance_jobs.clone(); let join_result = tokio::task::spawn_blocking(move || { let _maintenance_guard = maintenance_guard; - let _ = db_maintenance_jobs - .mark_phase(job_id, crate::middleware::DbMaintenanceJobPhase::Backup); - let backup_result = create_pre_repair_backup(&pool, &progress, job_id); - - let conn = match pool.get() { - Ok(conn) => conn, - Err(error) => { - let _ = db_maintenance_jobs.mark_failed( - job_id, - format!( - "Could not open database connection after pre-repair backup: {error}" - ), - ); - return; - } - }; - - let _ = db_maintenance_jobs - .mark_phase(job_id, crate::middleware::DbMaintenanceJobPhase::Repair); - let report = match backup_result { - Ok(backup_id) => db::attempt_db_repair( - &conn, - Some(db::DbRepairBackup { - backup_id: backup_id.clone(), - backup_type: "DB + config".to_owned(), - backup_path: crate::config::backups_dir() - .join(&backup_id) - .display() - .to_string(), - verified: true, - }), - ), - Err(error) => { - let backup_error = error.to_string(); - db::db_repair_aborted_for_backup_failure(&conn, &backup_error) - } - }; - tracing::info!( - target: "admin", - before_ok = report.before.ok(), - after_ok = report.after.as_ref().map(db::DbHealthSnapshot::ok), - steps = report.repair_steps.len(), - backup = report - .repair_backup - .as_ref() - .map(|backup| backup.backup_id.as_str()), - backup_error = report.repair_backup_error.as_deref(), - "Admin ran database repair attempt" - ); - let _ = db_maintenance_jobs.mark_finished(job_id, report); + run_db_repair_job(&pool, &progress, &db_maintenance_jobs, job_id); }) .await; if let Err(error) = join_result { - let _ = job_status.mark_failed(job_id, format!("Database repair task failed: {error}")); + let failure_recorded = + job_status.mark_failed(job_id, format!("Database repair task failed: {error}")); + if !failure_recorded { + tracing::debug!(target: "admin", job_id, %error, "Ignored task failure update for stale database repair job"); + } } - }); + })); Ok(render_db_repair_entry_response( &jar, @@ -398,7 +416,7 @@ pub(in crate::server) async fn admin_db_repair_progress_json( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(()) } }) @@ -454,7 +472,7 @@ fn db_repair_progress_payload( crate::middleware::DbMaintenanceJobStatus::Running { job_id, phase: crate::middleware::DbMaintenanceJobPhase::Starting, - .. + started_at: _, } => serde_json::json!({ "state": "running", "job_id": job_id, @@ -465,7 +483,7 @@ fn db_repair_progress_payload( crate::middleware::DbMaintenanceJobStatus::Running { job_id, phase: crate::middleware::DbMaintenanceJobPhase::Backup, - .. + started_at: _, } => { let backup_percent = backup_percent(backup_phase, backup_files_done, backup_files_total); @@ -481,7 +499,7 @@ fn db_repair_progress_payload( crate::middleware::DbMaintenanceJobStatus::Running { job_id, phase: crate::middleware::DbMaintenanceJobPhase::Repair, - .. + started_at: _, } => serde_json::json!({ "state": "running", "job_id": job_id, @@ -489,16 +507,20 @@ fn db_repair_progress_payload( "percent": 82, "done": false, }), - crate::middleware::DbMaintenanceJobStatus::Finished { job_id, .. } => serde_json::json!({ - "state": "finished", - "job_id": job_id, - "label": "Maintenance rebuild complete. Opening report...", - "percent": 100, - "done": true, - "redirect_url": db_repair_status_url(Some(job_id)), - }), + crate::middleware::DbMaintenanceJobStatus::Finished { job_id, report: _ } => { + serde_json::json!({ + "state": "finished", + "job_id": job_id, + "label": "Maintenance rebuild complete. Opening report...", + "percent": 100, + "done": true, + "redirect_url": db_repair_status_url(Some(job_id)), + }) + } crate::middleware::DbMaintenanceJobStatus::Failed { - job_id, message, .. + job_id, + message, + finished_at: _, } => serde_json::json!({ "state": "failed", "job_id": job_id, @@ -517,7 +539,7 @@ fn backup_percent(phase: u64, files_done: u64, files_total: u64) -> u64 { crate::middleware::backup_phase::COMPRESS => files_done .saturating_mul(30) .checked_div(files_total) - .map_or(55, |percent| 55 + percent), + .map_or(55, |percent| 55_u64.saturating_add(percent)), crate::middleware::backup_phase::DONE => 78, _ => 10, } @@ -564,7 +586,7 @@ pub(in crate::server) async fn admin_db_repair_status( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; Ok(()) } }) @@ -605,7 +627,7 @@ fn render_db_repair_running_response( ) .into_response(); if let Ok(refresh) = HeaderValue::from_str(&refresh) { - response.headers_mut().insert(header::REFRESH, refresh); + let _previous_value = response.headers_mut().insert(header::REFRESH, refresh); } response } @@ -618,11 +640,20 @@ fn render_db_repair_entry_response( let current_theme = crate::handlers::board::current_theme_from_jar(jar); match status { crate::middleware::DbMaintenanceJobStatus::Running { - job_id, started_at, .. + job_id, + started_at, + phase: _, } => render_db_repair_running_response(jar, csrf, *job_id, *started_at), crate::middleware::DbMaintenanceJobStatus::Idle - | crate::middleware::DbMaintenanceJobStatus::Finished { .. } - | crate::middleware::DbMaintenanceJobStatus::Failed { .. } => ( + | crate::middleware::DbMaintenanceJobStatus::Finished { + job_id: _, + report: _, + } + | crate::middleware::DbMaintenanceJobStatus::Failed { + job_id: _, + finished_at: _, + message: _, + } => ( jar.clone(), Html(crate::templates::admin_db_repair_idle_page( csrf, @@ -665,7 +696,9 @@ fn render_db_repair_status_response( ) .into_response(), crate::middleware::DbMaintenanceJobStatus::Running { - job_id, started_at, .. + job_id, + started_at, + phase: _, } => render_db_repair_running_response(jar, csrf, *job_id, *started_at), crate::middleware::DbMaintenanceJobStatus::Finished { job_id, report } => ( jar.clone(), @@ -726,7 +759,7 @@ mod tests { &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create admin session")?; Ok(()) @@ -782,7 +815,8 @@ mod tests { None, None, ) - .context("create repair-test thread")?; + .context("create repair-test thread") + .map(|_completed_value| ())?; conn.execute_batch(&format!( "PRAGMA foreign_keys=OFF; BEGIN; DELETE FROM boards WHERE short_name='{board_short}'; COMMIT; PRAGMA foreign_keys=ON;" @@ -849,7 +883,7 @@ mod tests { } async fn wait_for_repair_result(router: &Router) -> anyhow::Result { - for _ in 0..100 { + for _ in 0_i32..100_i32 { let body = repair_status_body(router).await?; if !body.contains("maintenance rebuild running") { return Ok(body); @@ -995,9 +1029,9 @@ media_max_active_content_size_bytes = 0 } { - let mut failure = PRE_REPAIR_BACKUP_FAILURE - .lock() - .map_err(|_| anyhow::anyhow!("backup failure mutex was poisoned"))?; + let mut failure = PRE_REPAIR_BACKUP_FAILURE.lock().map_err(|poison_error| { + anyhow::anyhow!("backup failure mutex was poisoned: {poison_error}") + })?; *failure = Some("simulated pre-repair backup failure".to_owned()); } @@ -1044,9 +1078,9 @@ media_max_active_content_size_bytes = 0 let body = wait_for_repair_result(&router).await?; { - let mut failure = PRE_REPAIR_BACKUP_FAILURE - .lock() - .map_err(|_| anyhow::anyhow!("backup failure mutex was poisoned"))?; + let mut failure = PRE_REPAIR_BACKUP_FAILURE.lock().map_err(|poison_error| { + anyhow::anyhow!("backup failure mutex was poisoned: {poison_error}") + })?; *failure = None; } diff --git a/src/handlers/admin/settings/network.rs b/src/handlers/admin/settings/network.rs index c115f535..b452011a 100644 --- a/src/handlers/admin/settings/network.rs +++ b/src/handlers/admin/settings/network.rs @@ -27,12 +27,12 @@ pub(in crate::server) async fn update_network_settings( .map(|cookie| cookie.value().to_owned()); tokio::task::spawn_blocking(move || -> Result { let conn = state.db.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let save_result = crate::config::admin::save_network(&form); match save_result { Ok(()) => Ok(admin_panel_redirect_anchor(if crate::config::admin::restart_pending().unwrap_or(true) { "Settings saved. Restart required: use Restart RustChan to apply these changes." } else { "Settings saved. No restart is required for the effective configuration." }, "network-security").into_response()), Err(error) => { - let mut fields = crate::config::admin::network_snapshot().map_err(|_| "unavailable".to_owned()); + let mut fields = crate::config::admin::network_snapshot().map_err(|snapshot_error| { tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); "unavailable".to_owned() }); if let Ok(fields) = &mut fields { for field in fields { if let Some(value) = form.get(field.definition.key) { field.input.clone_from(value); } diff --git a/src/handlers/admin/settings/runtime.rs b/src/handlers/admin/settings/runtime.rs index 20600c56..fe0b958b 100644 --- a/src/handlers/admin/settings/runtime.rs +++ b/src/handlers/admin/settings/runtime.rs @@ -29,12 +29,12 @@ pub(in crate::server) async fn update_runtime_settings( .map(|cookie| cookie.value().to_owned()); tokio::task::spawn_blocking(move || -> Result { let conn = state.db.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let save_result = crate::config::admin::runtime::save_section(section, &form); match save_result { Ok(()) => Ok(admin_panel_redirect_anchor(if crate::config::admin::restart_pending().unwrap_or(true) { "Settings saved. Restart required: use Restart RustChan to apply these changes." } else { "Settings saved. No restart is required for the effective configuration." }, section.key()).into_response()), Err(error) => { - let mut fields = crate::config::admin::runtime::section_snapshot(section).map_err(|_| "unavailable".to_owned()); + let mut fields = crate::config::admin::runtime::section_snapshot(section).map_err(|snapshot_error| { tracing::warn!(error = %snapshot_error, "operator settings snapshot unavailable"); "unavailable".to_owned() }); if let Ok(fields) = &mut fields { for field in fields { if let Some(value) = form.get(field.definition.key) { field.input.clone_from(value); } diff --git a/src/handlers/admin/settings/site.rs b/src/handlers/admin/settings/site.rs index 3a4ae9ea..b04c8684 100644 --- a/src/handlers/admin/settings/site.rs +++ b/src/handlers/admin/settings/site.rs @@ -81,10 +81,12 @@ pub(in crate::server) async fn update_site_settings( tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<()> { - let mut conn = pool.get()?; - let tx = conn.transaction().map_err(anyhow::Error::from)?; + let mut pooled_connection = pool.get()?; + let tx = pooled_connection + .transaction() + .map_err(anyhow::Error::from)?; let conn = &tx; - require_admin_session_sid(conn, session_id.as_deref())?; + require_admin_session_sid(conn, session_id.as_deref()).map(|_completed_value| ())?; let homepage_new_thread_badges_enabled = resolved_checkbox_setting( form.homepage_new_thread_badges_enabled.as_deref(), db::get_homepage_new_thread_badges_enabled(conn), diff --git a/src/handlers/admin/settings/themes.rs b/src/handlers/admin/settings/themes.rs index 588c184d..c71c7aec 100644 --- a/src/handlers/admin/settings/themes.rs +++ b/src/handlers/admin/settings/themes.rs @@ -397,7 +397,7 @@ pub(in crate::server) async fn create_theme( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let slug = db::sanitize_theme_slug(&form.slug); if slug.is_empty() { return Err(AppError::BadRequest("Theme slug is required.".into())); @@ -434,7 +434,18 @@ pub(in crate::server) async fn create_theme( .into_response(), ) } - other => Err(other), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => Err(other), }) } @@ -451,7 +462,7 @@ pub(in crate::server) async fn update_theme( let pool = state.db.clone(); move || -> Result<()> { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let existing_slug = db::sanitize_theme_slug(&form.existing_slug); let theme = db::get_theme(&conn, &existing_slug)? .ok_or_else(|| AppError::BadRequest("Theme not found.".into()))?; @@ -507,7 +518,18 @@ pub(in crate::server) async fn update_theme( .into_response(), ) } - other => Err(other), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => Err(other), }) } @@ -531,7 +553,7 @@ pub(in crate::server) async fn delete_theme( let pool = state.db.clone(); move || -> Result<()> { let mut conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + require_admin_session_sid(&conn, session_id.as_deref()).map(|_completed_value| ())?; let slug = db::sanitize_theme_slug(&form.slug); db::delete_custom_theme(&mut conn, &slug)?; db::sync_live_theme_state(&conn)?; diff --git a/src/handlers/admin/updates.rs b/src/handlers/admin/updates.rs index 790922e6..b9ac78f9 100644 --- a/src/handlers/admin/updates.rs +++ b/src/handlers/admin/updates.rs @@ -69,7 +69,10 @@ pub(super) async fn snapshot() -> Status { .ok() .and_then(|file| { let mut bytes = Vec::new(); - file.take(512 * 1024 + 1).read_to_end(&mut bytes).ok()?; + file.take(512 * 1024 + 1) + .read_to_end(&mut bytes) + .ok() + .map(|_completed_value| ())?; (bytes.len() <= 512 * 1024).then_some(bytes) }) .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); @@ -87,7 +90,7 @@ pub(in crate::server) async fn status( State(state): State, jar: CookieJar, ) -> Result { - authorize(&state, &jar).await?; + authorize(&state, &jar).await.map(|_completed_value| ())?; Ok(Json(snapshot().await).into_response()) } @@ -100,7 +103,7 @@ pub(in crate::server) async fn check( Form(form): Form, ) -> Result { require_admin_post_origin_and_csrf(&jar, &headers, Some(peer), form.csrf.as_deref())?; - authorize(&state, &jar).await?; + authorize(&state, &jar).await.map(|_completed_value| ())?; let _guard = state.maintenance_gate.try_begin("Software update check")?; let outcome = if updates::managed() { updates::request(&Request::Check).await.and_then(|reply| { @@ -170,7 +173,7 @@ pub(in crate::server) async fn install( if password.len() > 1024 || !crate::utils::crypto::verify_password(&password, &user.password_hash)? { - super::auth::record_login_fail(&key); + let _failure_count = super::auth::record_login_fail(&key); return Err(AppError::Forbidden( "Current password did not verify. No update started.".to_owned(), )); @@ -191,7 +194,7 @@ pub(in crate::server) async fn install( }) .await; let acknowledged = outcome.is_ok(); - tokio::spawn(async move { + drop(tokio::spawn(async move { let _guard = guard; loop { if updates::request(&Request::Status) @@ -202,7 +205,7 @@ pub(in crate::server) async fn install( } tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - }); + })); let reply = outcome.map_err(|_error| { AppError::BadRequest( "Updater unavailable; reload Software Updates to check the persisted result." diff --git a/src/handlers/banner.rs b/src/handlers/banner.rs index d8f55e4a..aa734fc1 100644 --- a/src/handlers/banner.rs +++ b/src/handlers/banner.rs @@ -115,7 +115,8 @@ pub(in crate::server) async fn serve_banner_asset( |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), |resp| { let mut resp = resp.map(axum::body::Body::new); - resp.headers_mut() + let _previous_value = resp + .headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); crate::cache::set_cache_control( resp.headers_mut(), @@ -267,7 +268,8 @@ mod tests { "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE id = ?3", rusqlite::params!["view_password", password_hash, board_id], ) - .context("protect board")?; + .context("protect board") + .map(|_completed_value| ())?; let banner_id = crate::db::insert_banner_asset( &conn, crate::models::BannerScope::Board, @@ -354,7 +356,8 @@ mod tests { "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE id = ?3", rusqlite::params!["view_password", password_hash, board_id], ) - .context("protect board")?; + .context("protect board") + .map(|_completed_value| ())?; let admin_hash = crate::utils::crypto::hash_password("hunter2").context("hash admin password")?; let admin_id = diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 3361e391..f04badb4 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -153,7 +153,10 @@ fn thread_unread_counts( .iter() .filter_map(|thread| { let marker = markers.get(&thread.id)?; - let unread = (thread.reply_count - marker.seen_reply_count).max(0); + let unread = thread + .reply_count + .saturating_sub(marker.seen_reply_count) + .max(0); (unread > 0).then_some((thread.id, unread)) }) .collect() @@ -169,10 +172,10 @@ fn json_response(status: StatusCode, payload: &T) -> Result Result { let mut response = json_response(response_status, &XhrErrorPayload { error: message })?; - response.headers_mut().insert( - "x-rustchan-error-status", - HeaderValue::from_str(error_status.as_str()) - .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?, + drop( + response.headers_mut().insert( + "x-rustchan-error-status", + HeaderValue::from_str(error_status.as_str()) + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?, + ), ); if error_status == StatusCode::SERVICE_UNAVAILABLE { - response - .headers_mut() - .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); + drop( + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("1")), + ); } Ok(response) } @@ -210,10 +217,12 @@ pub(super) fn xhr_handled_error_response(status: StatusCode, message: &str) -> R pub(super) fn xhr_redirect_response(target: &str) -> Result { let mut response = Response::new(axum::body::Body::empty()); *response.status_mut() = StatusCode::NO_CONTENT; - response.headers_mut().insert( - X_RUSTCHAN_REDIRECT_HEADER, - HeaderValue::from_str(target) - .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?, + drop( + response.headers_mut().insert( + X_RUSTCHAN_REDIRECT_HEADER, + HeaderValue::from_str(target) + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?, + ), ); Ok(response) } @@ -233,9 +242,10 @@ pub(super) fn xhr_post_error_response(error: AppError) -> Result { xhr_handled_error_response(StatusCode::UNPROCESSABLE_ENTITY, &message) } AppError::Forbidden(message) => xhr_handled_error_response(StatusCode::FORBIDDEN, &message), - AppError::BannedUser { reason, .. } => { - xhr_redirect_response(&banned_page_redirect_url(&reason)) - } + AppError::BannedUser { + reason, + csrf_token: _, + } => xhr_redirect_response(&banned_page_redirect_url(&reason)), AppError::UploadTooLarge(message) => { xhr_handled_error_response(StatusCode::PAYLOAD_TOO_LARGE, &message) } @@ -271,7 +281,16 @@ pub(super) fn handled_post_error_status( AppError::BadRequest(message) => Ok((StatusCode::UNPROCESSABLE_ENTITY, message)), AppError::UploadTooLarge(message) => Ok((StatusCode::PAYLOAD_TOO_LARGE, message)), AppError::InvalidMediaType(message) => Ok((StatusCode::UNSUPPORTED_MEDIA_TYPE, message)), - other => Err(other), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => Err(other), } } @@ -604,7 +623,7 @@ fn board_unlock_retry_after_secs(attempt_key: &str) -> Option { let (count, window_start) = *BOARD_UNLOCK_FAILS.get(attempt_key)?; let elapsed = now_secs.saturating_sub(window_start); if elapsed > board_password_fail_window_secs() { - BOARD_UNLOCK_FAILS.remove(attempt_key); + let _previous_value = BOARD_UNLOCK_FAILS.remove(attempt_key); return None; } if count < board_password_fail_limit() { @@ -630,7 +649,7 @@ fn record_board_unlock_failure(attempt_key: &str) { } fn clear_board_unlock_failures(attempt_key: &str) { - BOARD_UNLOCK_FAILS.remove(attempt_key); + let _previous_value = BOARD_UNLOCK_FAILS.remove(attempt_key); } fn board_unlock_rate_limit_message(retry_after_secs: u64) -> String { @@ -671,13 +690,13 @@ fn board_access_page_response( ) -> Response { let mut resp = Html(html).into_response(); *resp.status_mut() = status; - resp.headers_mut().insert( + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::cache::CACHE_CONTROL_PRIVATE_NO_STORE), - ); + )); if let Some(retry_after_secs) = retry_after_secs { if let Ok(retry_after) = HeaderValue::from_str(&retry_after_secs.to_string()) { - resp.headers_mut().insert(header::RETRY_AFTER, retry_after); + drop(resp.headers_mut().insert(header::RETRY_AFTER, retry_after)); } } (jar, resp).into_response() @@ -743,7 +762,7 @@ fn split_catalog_threads( for thread in threads { if let Some(pref) = prefs.get(&thread.id) { if pref.pinned { - pinned_ids.insert(thread.id); + let _completed_value = pinned_ids.insert(thread.id); } if pref.hidden { hidden.push(thread); diff --git a/src/handlers/board/access_preferences.rs b/src/handlers/board/access_preferences.rs index e46a802b..53f20a05 100644 --- a/src/handlers/board/access_preferences.rs +++ b/src/handlers/board/access_preferences.rs @@ -29,6 +29,13 @@ const OWNED_POSTS_COOKIE_MAX_LEN: usize = 3_800; pub(in crate::server::handlers) fn self_action_window_secs() -> i64 { CONFIG.operator.self_action_window_secs } + +/// Bound self-action grants without extending a malformed restored timestamp on overflow. +pub(in crate::server::handlers) fn self_action_expiry(created_at: i64) -> Result { + created_at + .checked_add(self_action_window_secs()) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("self-action expiry timestamp overflow"))) +} /// Board activity cookie used by this handler. const BOARD_ACTIVITY_COOKIE: &str = "rustchan_board_activity"; /// Thread activity cookie used by this handler. @@ -142,7 +149,7 @@ fn prune_owned_post_grants_for_cookie(mut grants: Vec) -> Vec HashMap let mut markers = HashMap::new(); for entry in parse_activity_cookie_entries(value) { if let Some(marker) = parse_board_activity_marker(entry, now) { - markers.insert(marker.board_id, marker); + let _previous_value = markers.insert(marker.board_id, marker); } } markers @@ -307,7 +314,7 @@ fn parse_thread_activity_cookie(value: &str) -> HashMap anyhow::Result<()> { + anyhow::ensure!( + super::self_action_expiry(i64::MAX).is_err(), + "overflowed expiry must fail closed" + ); + let created_at = 1_700_000_000_i64; + let expected = created_at + .checked_add(self_action_window_secs()) + .ok_or_else(|| anyhow::anyhow!("test expiry overflow"))?; + anyhow::ensure!( + super::self_action_expiry(created_at)? == expected, + "valid expiry must preserve the configured window" + ); + Ok(()) + } use super::{ board_activity_markers_from_jar, ensure_csrf_with_secure, owned_post_grants_from_jar, owned_posts_cookie, owned_posts_cookie_value, prune_board_activity_markers, @@ -1207,7 +1231,7 @@ mod tests { async fn user_preferences_remain_settable_on_plain_http_onion_transport() -> AnyResult<()> { let jar = CookieJar::new().add(TestCookie::new("csrf_token", "csrf123")); let mut headers = HeaderMap::new(); - headers.insert( + let _previous_value = headers.insert( header::HOST, HeaderValue::from_static( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion", diff --git a/src/handlers/board/catalog.rs b/src/handlers/board/catalog.rs index 5a04feb4..5e96ea7b 100644 --- a/src/handlers/board/catalog.rs +++ b/src/handlers/board/catalog.rs @@ -217,14 +217,14 @@ pub(in crate::server) async fn catalog( .status(StatusCode::NOT_MODIFIED) .body(axum::body::Body::empty()) .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - resp.headers_mut().insert( + drop(resp.headers_mut().insert( "etag", HeaderValue::from_str(&etag).unwrap_or_else(|_| HeaderValue::from_static("\"0\"")), - ); - resp.headers_mut().insert( + )); + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(activity_html_cache_control(activity_markers_enabled)), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); return Ok((jar, resp).into_response()); } @@ -255,12 +255,12 @@ pub(in crate::server) async fn catalog( ); let mut resp = Html(html).into_response(); if let Ok(v) = HeaderValue::from_str(&etag) { - resp.headers_mut().insert("etag", v); + drop(resp.headers_mut().insert("etag", v)); } - resp.headers_mut().insert( + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(activity_html_cache_control(activity_markers_enabled)), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); Ok((jar, resp).into_response()) } diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index 0395fb3a..8adb8817 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -2,12 +2,11 @@ use super::{ board_access_cookie_from_jar, board_access_preflight, current_theme_from_jar, db, handled_post_error_status, identity_key, index_reply_previews, index_threads_per_page, is_xml_http_request, make_scoped_csrf_form_token, parse_post_multipart, posting, - remember_owned_post_until_with_secure, render, self_action_window_secs, - should_set_public_secure_cookie, unlock_redirect_url, user_preferences_from_jar, - xhr_error_response, xhr_post_error_response, xhr_redirect_response, AppError, AppState, - BoardAccessDecision, BoardAccessRequirement, CookieJar, HashMap, HeaderMap, Html, Multipart, - Path, PostFormData, Redirect, Response, Result, SecureCookieContext, State, - ADMIN_SESSION_COOKIE, CONFIG, + remember_owned_post_until_with_secure, render, should_set_public_secure_cookie, + unlock_redirect_url, user_preferences_from_jar, xhr_error_response, xhr_post_error_response, + xhr_redirect_response, AppError, AppState, BoardAccessDecision, BoardAccessRequirement, + CookieJar, HashMap, HeaderMap, Html, Multipart, Path, PostFormData, Redirect, Response, Result, + SecureCookieContext, State, ADMIN_SESSION_COOKIE, CONFIG, }; use axum::response::IntoResponse as _; @@ -261,7 +260,7 @@ pub(in crate::server) async fn create_thread( submit_result.thread_id, submit_result.post_id, &submit_result.deletion_token, - submit_result.created_at + self_action_window_secs(), + super::self_action_expiry(submit_result.created_at)?, should_set_public_secure_cookie(&req_headers, secure_context), ); diff --git a/src/handlers/board/media.rs b/src/handlers/board/media.rs index 065e67fb..6f0a499f 100644 --- a/src/handlers/board/media.rs +++ b/src/handlers/board/media.rs @@ -74,10 +74,16 @@ fn stale_webm_redirect_path(base: &std::path::Path, media_path: &str) -> Option< } let stem = media_path.get(..media_path.len().saturating_sub(4))?; let webm_path = format!("{stem}.webm"); - safe_board_media_file(base, &webm_path).ok()?; + safe_board_media_file(base, &webm_path) + .ok() + .map(|_completed_value| ())?; let location = format!("/boards/{webm_path}"); - HeaderValue::from_str(&location).ok()?; - axum::http::Uri::try_from(location.as_str()).ok()?; + HeaderValue::from_str(&location) + .ok() + .map(|_completed_value| ())?; + axum::http::Uri::try_from(location.as_str()) + .ok() + .map(|_completed_value| ())?; Some(location) } @@ -89,11 +95,17 @@ fn stale_waveform_redirect_path(base: &std::path::Path, media_path: &str) -> Opt } let png_path = std::path::Path::new(media_path).with_extension("png"); let relative = png_path.to_str()?; - safe_board_media_file(base, relative).ok()?; + safe_board_media_file(base, relative) + .ok() + .map(|_completed_value| ())?; let location = format!("/boards/{relative}"); // Validate untrusted stored paths before constructing an HTTP redirect. - HeaderValue::from_str(&location).ok()?; - axum::http::Uri::try_from(location.as_str()).ok()?; + HeaderValue::from_str(&location) + .ok() + .map(|_completed_value| ())?; + axum::http::Uri::try_from(location.as_str()) + .ok() + .map(|_completed_value| ())?; Some(location) } @@ -110,7 +122,6 @@ pub(in crate::server) async fn serve_board_media( jar: CookieJar, req: axum::extract::Request, ) -> Response { - use axum::http::StatusCode; use std::path::PathBuf; use tower::ServiceExt as _; use tower_http::services::ServeFile; @@ -167,7 +178,7 @@ pub(in crate::server) async fn serve_board_media( .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf")); - let target = match safe_board_media_file(&base, &media_path) { + let updated_target = match safe_board_media_file(&base, &media_path) { Ok(path) => Some(path), Err(error) if is_not_found_error(&error) @@ -183,75 +194,90 @@ pub(in crate::server) async fn serve_board_media( Err(_) => return StatusCode::NOT_FOUND.into_response(), }; - if let Some(target) = target { + if let Some(updated_target) = updated_target { // File present — forward the real request (with Range, ETag, etc.) to // ServeFile so it can respond with 206 Partial Content when needed. // iOS Safari requires Range request support to play video — dropping // the request headers caused it to receive 200 instead of 206 and // refuse playback on videos it tried to stream in chunks. let req = req.map(|_| axum::body::Body::empty()); - ServeFile::new(&target).oneshot(req).await.map_or_else( - |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), - |resp| { - // The waveform worker can remove the SVG between our path check - // and ServeFile opening it. Resolve the completed PNG again. - if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) - .then(|| stale_waveform_redirect_path(&base, &media_path)) - .flatten() - { - return Redirect::temporary(&path).into_response(); - } - if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) - .then(|| stale_webm_redirect_path(&base, &media_path)) - .flatten() - { - return Redirect::permanent(&path).into_response(); - } - let mut resp = resp.map(axum::body::Body::new); - crate::cache::set_cache_control( - resp.headers_mut(), - board_media_cache_control( - access_context.board.access_mode.requires_view_password(), - is_board_favicon, - has_version, - ), - ); - if is_generated_svg_placeholder_thumb(&media_path) { - resp.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml")); - resp.headers_mut() - .insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); - resp.headers_mut().insert( - CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'none'; script-src 'none'"), - ); - } else if let Some(ct) = media_content_type(&target) { - resp.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static(ct)); - } else { - resp.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("application/octet-stream"), - ); - resp.headers_mut() - .insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); - let filename = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("download.bin") - .replace(['\\', '"'], "_"); - if let Ok(value) = - HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) + ServeFile::new(&updated_target) + .oneshot(req) + .await + .map_or_else( + |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), + |resp| { + // The waveform worker can remove the SVG between our path check + // and ServeFile opening it. Resolve the completed PNG again. + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_waveform_redirect_path(&base, &media_path)) + .flatten() { - resp.headers_mut().insert(CONTENT_DISPOSITION, value); + return Redirect::temporary(&path).into_response(); } - } - if is_pdf { - apply_pdf_embed_headers(resp.headers_mut()); - } - resp.into_response() - }, - ) + if let Some(path) = (resp.status() == StatusCode::NOT_FOUND) + .then(|| stale_webm_redirect_path(&base, &media_path)) + .flatten() + { + return Redirect::permanent(&path).into_response(); + } + let mut resp = resp.map(axum::body::Body::new); + crate::cache::set_cache_control( + resp.headers_mut(), + board_media_cache_control( + access_context.board.access_mode.requires_view_password(), + is_board_favicon, + has_version, + ), + ); + if is_generated_svg_placeholder_thumb(&media_path) { + drop( + resp.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml")), + ); + drop( + resp.headers_mut().insert( + X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ), + ); + drop(resp.headers_mut().insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; script-src 'none'"), + )); + } else if let Some(ct) = media_content_type(&updated_target) { + drop( + resp.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(ct)), + ); + } else { + drop(resp.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + )); + drop( + resp.headers_mut().insert( + X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ), + ); + let filename = updated_target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("download.bin") + .replace(['\\', '"'], "_"); + let disposition = format!("attachment; filename=\"{filename}\""); + let parsed_disposition = HeaderValue::from_str(&disposition); + if let Ok(value) = parsed_disposition { + drop(resp.headers_mut().insert(CONTENT_DISPOSITION, value)); + } + } + if is_pdf { + apply_pdf_embed_headers(resp.headers_mut()); + } + resp.into_response() + }, + ) } else if let Some(redirect_path) = stale_waveform_redirect_path(&base, &media_path) { Redirect::temporary(&redirect_path).into_response() } else if let Some(redirect_path) = stale_webm_redirect_path(&base, &media_path) { @@ -277,13 +303,13 @@ const fn board_media_cache_control( } fn apply_pdf_embed_headers(headers: &mut HeaderMap) { - headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("SAMEORIGIN")); - headers.insert( + drop(headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("SAMEORIGIN"))); + drop(headers.insert( CONTENT_SECURITY_POLICY, HeaderValue::from_static( "default-src 'none'; frame-ancestors 'self'; sandbox allow-same-origin allow-scripts", ), - ); + )); } // GET /api/post/{board}/{post_id} @@ -393,8 +419,6 @@ pub(in crate::server) async fn redirect_to_post( Path((board_short, post_id)): Path<(String, i64)>, jar: CookieJar, ) -> impl axum::response::IntoResponse { - use axum::response::Redirect; - let board_short_for_url = board_short.clone(); let admin_session_id = jar .get(ADMIN_SESSION_COOKIE) diff --git a/src/handlers/board/pages.rs b/src/handlers/board/pages.rs index e88814b2..a1dc6c02 100644 --- a/src/handlers/board/pages.rs +++ b/src/handlers/board/pages.rs @@ -210,10 +210,10 @@ pub(in crate::server) async fn index( )) .into_response(); let activity_markers_enabled = homepage_thread_badges_enabled || homepage_reply_badges_enabled; - response.headers_mut().insert( + drop(response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(activity_html_cache_control(activity_markers_enabled)), - ); + )); crate::cache::insert_vary_cookie(response.headers_mut()); Ok((jar, response).into_response()) } @@ -334,7 +334,11 @@ pub(in crate::server) async fn board_index( .iter() .filter_map(|summary| { let marker = thread_activity_markers.get(&summary.thread.id)?; - let unread = (summary.thread.reply_count - marker.seen_reply_count).max(0); + let unread = summary + .thread + .reply_count + .saturating_sub(marker.seen_reply_count) + .max(0); (unread > 0).then_some((summary.thread.id, unread)) }) .collect::>() @@ -412,14 +416,14 @@ pub(in crate::server) async fn board_index( .status(StatusCode::NOT_MODIFIED) .body(axum::body::Body::empty()) .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - resp.headers_mut().insert( + drop(resp.headers_mut().insert( "etag", HeaderValue::from_str(&etag).unwrap_or_else(|_| HeaderValue::from_static("\"0\"")), - ); - resp.headers_mut().insert( + )); + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(activity_html_cache_control(activity_markers_enabled)), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); return Ok((jar, resp).into_response()); } @@ -449,12 +453,12 @@ pub(in crate::server) async fn board_index( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let mut resp = Html(html).into_response(); if let Ok(v) = HeaderValue::from_str(&etag) { - resp.headers_mut().insert("etag", v); + drop(resp.headers_mut().insert("etag", v)); } - resp.headers_mut().insert( + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(activity_html_cache_control(activity_markers_enabled)), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); Ok((jar, resp).into_response()) } diff --git a/src/handlers/board/reports.rs b/src/handlers/board/reports.rs index 27f38843..74098c4c 100644 --- a/src/handlers/board/reports.rs +++ b/src/handlers/board/reports.rs @@ -84,7 +84,8 @@ pub(in crate::server) async fn file_report( } // Use the DB's thread_id for the redirect — not the user-submitted value. let authoritative_thread_id = post.thread_id; - let _ = db::file_report(&conn, post_id, &reason, &ip_hash)?; + // The report ID does not affect the authoritative thread redirect. + db::file_report(&conn, post_id, &reason, &ip_hash).map(|_report_id| ())?; Ok(authoritative_thread_id) } }) diff --git a/src/handlers/board/tests.rs b/src/handlers/board/tests.rs index d8856b67..f4186967 100644 --- a/src/handlers/board/tests.rs +++ b/src/handlers/board/tests.rs @@ -54,7 +54,7 @@ fn seed_post_password_board( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2, allow_editing = 1, allow_self_delete = 1 WHERE id = ?3", rusqlite::params!["post_password", password_hash, board_id], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; let post = crate::db::NewPost { thread_id: 0, board_id, @@ -80,7 +80,7 @@ fn seed_post_password_board( let poll = crate::db::threads::PollInsert { question: "pick one", options: &["yes".to_owned(), "no".to_owned()], - expires_at: chrono::Utc::now().timestamp() + 3600, + expires_at: chrono::Utc::now().timestamp().saturating_add(3600), }; let (thread_id, post_id, poll_id) = crate::db::create_thread_with_optional_poll( &conn, @@ -257,7 +257,8 @@ fn create_reply_on_thread( is_op: false, }; crate::db::create_reply_with_thread_update(&conn, &reply, "", true, None) - .context("create reply")?; + .context("create reply") + .map(|_completed_value| ())?; Ok(()) } @@ -290,9 +291,9 @@ fn update_cookie_store(store: &mut HashMap, headers: &HeaderMap) continue; }; if cookie_value.is_empty() { - store.remove(name); + let _previous_value = store.remove(name); } else { - store.insert(name.to_owned(), cookie_value.to_owned()); + let _previous_value = store.insert(name.to_owned(), cookie_value.to_owned()); } } } @@ -322,7 +323,7 @@ fn activity_restore_js_uses_explicit_page_markers() -> anyhow::Result<()> { #[test] fn board_activity_cookie_removal_keeps_root_path_attributes() -> anyhow::Result<()> { let mut headers = HeaderMap::new(); - headers.insert( + let _previous_value = headers.insert( header::COOKIE, "rustchan_board_activity=v1|1.100.1.200" .parse() @@ -348,7 +349,7 @@ fn board_activity_cookie_removal_keeps_root_path_attributes() -> anyhow::Result< #[test] fn thread_activity_cookie_removal_keeps_root_path_attributes() -> anyhow::Result<()> { let mut headers = HeaderMap::new(); - headers.insert( + let _previous_value = headers.insert( header::COOKIE, "rustchan_thread_activity=v1|1.2.200" .parse() @@ -490,7 +491,7 @@ async fn post_password_board_write_actions_require_unlock() -> anyhow::Result<() Some("/secret/unlock?return_to=%2Fsecret") ); - let (boundary, body) = + let (response_boundary, resolved_body) = crate::test_support::multipart_body(&[("_csrf", "csrf123"), ("body", "reply")], None); let reply_response = router .clone() @@ -500,11 +501,11 @@ async fn post_password_board_write_actions_require_unlock() -> anyhow::Result<() .uri(format!("/secret/thread/{thread_id}")) .header( header::CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), + format!("multipart/form-data; boundary={response_boundary}"), ) .header(header::COOKIE, "csrf_token=csrf123") .extension(crate::test_support::connect_info()) - .body(Body::from(body)) + .body(Body::from(resolved_body)) .context("request")?, ) .await @@ -609,7 +610,8 @@ async fn self_delete_requires_owned_post_cookie() -> anyhow::Result<()> { "UPDATE boards SET allow_self_delete = 1 WHERE id = ?1", rusqlite::params![board_id], ) - .context("enable self delete")?; + .context("enable self delete") + .map(|_completed_value| ())?; let op = crate::db::NewPost { thread_id: 0, board_id, @@ -753,7 +755,8 @@ async fn search_returns_results_without_500() -> anyhow::Result<()> { is_op: true, }; crate::db::create_thread_with_optional_poll(&conn, board_id, None, &post, "", None, None) - .context("create thread")?; + .context("create thread") + .map(|_completed_value| ())?; } let router = Router::new() @@ -788,7 +791,9 @@ async fn search_without_q_param_returns_empty_results_page() -> anyhow::Result<( let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -823,14 +828,16 @@ async fn locked_board_search_returns_forbidden_unlock_page() -> anyhow::Result<( let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "slock", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "slock", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'slock'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -872,7 +879,9 @@ async fn create_thread_accepts_valid_multipart_submission() -> anyhow::Result<() let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -913,7 +922,9 @@ async fn create_thread_xhr_returns_explicit_redirect_header() -> anyhow::Result< let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -968,7 +979,9 @@ async fn create_thread_xhr_validation_failure_returns_json_error() -> anyhow::Re let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -1011,14 +1024,14 @@ async fn create_thread_xhr_validation_failure_returns_json_error() -> anyhow::Re Some(StatusCode::UNPROCESSABLE_ENTITY.as_str()) ); - let body = String::from_utf8( + let resolved_body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await .context("response body")? .to_vec(), ) .context("utf8 body")?; - anyhow::ensure!(body.contains("\"error\"")); + anyhow::ensure!(resolved_body.contains("\"error\"")); Ok(()) } @@ -1123,7 +1136,9 @@ async fn create_thread_rejects_mime_mismatch_with_415_inline_error() -> anyhow:: let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -1152,15 +1167,15 @@ async fn create_thread_rejects_mime_mismatch_with_415_inline_error() -> anyhow:: .context("response")?; ensure_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - let body = String::from_utf8( + let resolved_body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await .context("response body")? .to_vec(), ) .context("utf8 body")?; - anyhow::ensure!(body.contains("post-error-banner")); - anyhow::ensure!(body.contains("File type not allowed")); + anyhow::ensure!(resolved_body.contains("post-error-banner")); + anyhow::ensure!(resolved_body.contains("File type not allowed")); Ok(()) } @@ -1169,7 +1184,9 @@ async fn create_thread_rejects_truncated_png_with_422_inline_error() -> anyhow:: let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -1199,15 +1216,15 @@ async fn create_thread_rejects_truncated_png_with_422_inline_error() -> anyhow:: .context("response")?; ensure_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - let body = String::from_utf8( + let resolved_body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await .context("response body")? .to_vec(), ) .context("utf8 body")?; - anyhow::ensure!(body.contains("post-error-banner")); - anyhow::ensure!(body.contains("image header is malformed")); + anyhow::ensure!(resolved_body.contains("post-error-banner")); + anyhow::ensure!(resolved_body.contains("image header is malformed")); Ok(()) } @@ -1277,7 +1294,8 @@ async fn absent_homepage_reply_badge_setting_defaults_to_enabled() -> anyhow::Re "DELETE FROM site_settings WHERE key = 'homepage_new_reply_badges_enabled'", [], ) - .context("delete setting")?; + .context("delete setting") + .map(|_completed_value| ())?; anyhow::ensure!(crate::db::get_homepage_new_reply_badges_enabled(&conn)); Ok(()) @@ -1304,7 +1322,7 @@ async fn homepage_reply_toggle_off_suppresses_only_homepage_reply_badges() -> an .await .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; create_reply_on_thread(&state, board_id, thread_id, "reply")?; let home_response = router @@ -1433,7 +1451,7 @@ async fn homepage_thread_toggle_off_does_not_suppress_homepage_reply_badges() -> .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; create_reply_on_thread(&state, board_id, thread_id, "reply")?; let response = router @@ -1574,7 +1592,7 @@ async fn new_thread_after_board_baseline_shows_homepage_badge() -> anyhow::Resul .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let response = router .oneshot( @@ -1663,7 +1681,7 @@ async fn homepage_thread_and_reply_badges_can_render_together() -> anyhow::Resul .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; create_reply_on_thread(&state, board_id, thread_id, "reply")?; let response = router @@ -1707,7 +1725,7 @@ async fn board_index_visit_clears_homepage_new_thread_badge() -> anyhow::Result< .await .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let clear_response = router .clone() @@ -1761,7 +1779,7 @@ async fn board_catalog_visit_clears_homepage_new_thread_badge() -> anyhow::Resul .await .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let clear_response = router .clone() @@ -1815,7 +1833,7 @@ async fn thread_visit_clears_homepage_new_thread_badge() -> anyhow::Result<()> { .await .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let clear_response = router .clone() @@ -1877,7 +1895,7 @@ async fn conditional_thread_visit_that_marks_activity_read_returns_full_response .context("thread etag")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let clear_response = router .clone() @@ -1917,7 +1935,7 @@ async fn conditional_board_activity_pages_return_full_response_when_tracking_ena ) -> anyhow::Result<()> { let state = crate::test_support::app_state(); set_new_activity_settings(&state, true, true, true)?; - seed_board_with_thread(&state, "tech", "op")?; + seed_board_with_thread(&state, "tech", "op").map(|_completed_value| ())?; let router = activity_router(state); for uri in ["/tech", "/tech/catalog"] { @@ -2478,7 +2496,7 @@ async fn thread_updates_clear_homepage_new_thread_badge_cookie() -> anyhow::Resu .context("response")?; update_cookie_store(&mut cookies, baseline.headers()); - create_thread_on_board(&state, board_id, "new thread")?; + create_thread_on_board(&state, board_id, "new thread").map(|_completed_value| ())?; let badge_home = router .clone() @@ -2540,7 +2558,8 @@ async fn password_protected_board_does_not_leak_homepage_new_activity_badge() -> "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE id = ?3", rusqlite::params!["view_password", password_hash, board_id], ) - .context("update board access")?; + .context("update board access") + .map(|_completed_value| ())?; } let router = activity_router(state); let cookie = format!( @@ -2578,7 +2597,8 @@ async fn thread_updates_rejects_thread_id_from_other_board() -> anyhow::Result<( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE id = ?3", rusqlite::params!["view_password", password_hash, secret_board_id], ) - .context("protect secret board")?; + .context("protect secret board") + .map(|_completed_value| ())?; } create_reply_on_thread( &state, @@ -2733,7 +2753,7 @@ async fn catalog_baseline_tracks_only_highest_priority_threads_within_cookie_lim set_new_activity_settings(&state, true, true, true)?; let (board_id, first_thread_id) = seed_board_with_thread(&state, "tech", "op")?; let mut created_thread_ids = vec![first_thread_id]; - for index in 0..120 { + for index in 0_i32..120_i32 { created_thread_ids.push(create_thread_on_board( &state, board_id, @@ -2768,7 +2788,7 @@ async fn catalog_baseline_tracks_only_highest_priority_threads_within_cookie_lim }) .context("thread activity cookie")?; let mut cookie_headers = HeaderMap::new(); - cookie_headers.insert( + let _previous_value = cookie_headers.insert( header::COOKIE, format!("rustchan_thread_activity={cookie_value}") .parse() @@ -2803,14 +2823,17 @@ async fn create_thread_xhr_banned_user_redirects_to_banned_page() -> anyhow::Res let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; crate::db::add_ban( &conn, &crate::utils::crypto::hash_ip("127.0.0.1", &crate::config::CONFIG.cookie_secret), "testing ban", None, ) - .context("add ban")?; + .context("add ban") + .map(|_completed_value| ())?; } let router = Router::new() @@ -2855,12 +2878,15 @@ async fn create_thread_xhr_captcha_failure_returns_inline_json_error() -> anyhow let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_captcha = 1 WHERE short_name = 'test'", [], ) - .context("enable captcha")?; + .context("enable captcha") + .map(|_completed_value| ())?; } let router = Router::new() @@ -2905,14 +2931,14 @@ async fn create_thread_xhr_captcha_failure_returns_inline_json_error() -> anyhow Some(StatusCode::UNPROCESSABLE_ENTITY.as_str()) ); - let body = String::from_utf8( + let resolved_body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await .context("response body")? .to_vec(), ) .context("utf8 body")?; - anyhow::ensure!(body.contains("CAPTCHA verification failed")); + anyhow::ensure!(resolved_body.contains("CAPTCHA verification failed")); Ok(()) } @@ -2956,7 +2982,7 @@ async fn duplicate_report_redirects_back_without_500() -> anyhow::Result<()> { .route("/report", post(super::file_report)) .with_state(state.clone()); - for _ in 0..2 { + for _ in 0_i32..2_i32 { let response = router .clone() .oneshot( @@ -3053,7 +3079,9 @@ async fn banned_actor_cannot_file_reports_and_unban_restores_reporting() -> anyh let ip_hash = loopback_ip_hash(); { let conn = state.db.get().context("db connection")?; - crate::db::add_ban(&conn, &ip_hash, "report ban reason", None).context("add ban")?; + crate::db::add_ban(&conn, &ip_hash, "report ban reason", None) + .context("add ban") + .map(|_completed_value| ())?; } let router = Router::new() @@ -3105,7 +3133,8 @@ async fn banned_actor_cannot_file_reports_and_unban_restores_reporting() -> anyh "DELETE FROM bans WHERE ip_hash = ?1", rusqlite::params![ip_hash], ) - .context("remove ban")?; + .context("remove ban") + .map(|_completed_value| ())?; } let after_unban = router @@ -3153,7 +3182,7 @@ fn seed_votable_poll(state: &crate::middleware::AppState) -> anyhow::Result let poll = crate::db::threads::PollInsert { question: "pick one", options: &["yes".to_owned(), "no".to_owned()], - expires_at: chrono::Utc::now().timestamp() + 3600, + expires_at: chrono::Utc::now().timestamp().saturating_add(3600), }; let (_, _, poll_id) = crate::db::create_thread_with_optional_poll( &conn, @@ -3180,7 +3209,9 @@ async fn banned_actor_cannot_vote_and_unban_restores_voting() -> anyhow::Result< let ip_hash = loopback_ip_hash(); { let conn = state.db.get().context("db connection")?; - crate::db::add_ban(&conn, &ip_hash, "vote ban reason", None).context("add ban")?; + crate::db::add_ban(&conn, &ip_hash, "vote ban reason", None) + .context("add ban") + .map(|_completed_value| ())?; } let router = Router::new() @@ -3230,7 +3261,8 @@ async fn banned_actor_cannot_vote_and_unban_restores_voting() -> anyhow::Result< "DELETE FROM bans WHERE ip_hash = ?1", rusqlite::params![ip_hash], ) - .context("remove ban")?; + .context("remove ban") + .map(|_completed_value| ())?; } let after_unban = router @@ -3251,7 +3283,9 @@ async fn create_thread_rejects_uploads_on_upload_disabled_board() -> anyhow::Res let state = crate::test_support::app_state(); { let mut conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; crate::db::update_board_settings( &mut conn, 1, @@ -3325,14 +3359,16 @@ async fn view_locked_catalog_renders_unlock_page() -> anyhow::Result<()> { let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "secret", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "secret", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'secret'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -3369,14 +3405,16 @@ async fn unlock_board_access_sets_cookie_and_redirects() -> anyhow::Result<()> { let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "secret", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "secret", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'secret'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -3424,14 +3462,16 @@ async fn unlock_board_access_rejects_malformed_return_to_and_uses_board_default( let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "secret", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "secret", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'secret'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -3470,14 +3510,16 @@ async fn changing_board_password_invalidates_existing_unlock_cookie() -> anyhow: let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "secret", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "secret", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'secret'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -3519,10 +3561,11 @@ async fn changing_board_password_invalidates_existing_unlock_cookie() -> anyhow: "UPDATE boards SET access_password_hash = ?1 WHERE short_name = 'secret'", rusqlite::params![password_hash], ) - .context("change board password")?; + .context("change board password") + .map(|_completed_value| ())?; } - let response = router + let resolved_response = router .oneshot( Request::builder() .method("GET") @@ -3535,7 +3578,7 @@ async fn changing_board_password_invalidates_existing_unlock_cookie() -> anyhow: .await .context("catalog response")?; - ensure_eq!(response.status(), StatusCode::FORBIDDEN); + ensure_eq!(resolved_response.status(), StatusCode::FORBIDDEN); Ok(()) } @@ -3618,7 +3661,7 @@ async fn theme_redirect_persists_no_js_theme_without_csrf() -> anyhow::Result<() ensure_eq!(rejected.status(), StatusCode::FORBIDDEN); anyhow::ensure!(rejected.headers().get(header::SET_COOKIE).is_none()); - let accepted = router + let resolved_accepted = router .oneshot( Request::builder() .method("GET") @@ -3630,15 +3673,15 @@ async fn theme_redirect_persists_no_js_theme_without_csrf() -> anyhow::Result<() .await .context("accepted response")?; - ensure_eq!(accepted.status(), StatusCode::SEE_OTHER); + ensure_eq!(resolved_accepted.status(), StatusCode::SEE_OTHER); ensure_eq!( - accepted + resolved_accepted .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()), Some("/secret/catalog") ); - anyhow::ensure!(accepted + anyhow::ensure!(resolved_accepted .headers() .get_all(header::SET_COOKIE) .iter() @@ -3650,7 +3693,7 @@ async fn theme_redirect_persists_no_js_theme_without_csrf() -> anyhow::Result<() #[test] fn user_preferences_from_jar_defaults_and_ignores_invalid_values() -> anyhow::Result<()> { let mut headers = HeaderMap::new(); - headers.insert( + let _previous_value = headers.insert( header::COOKIE, "rustchan_hide_nsfw=maybe; rustchan_video_audio=loud; rustchan_preferred_view=grid; rustchan_activity_badges=maybe" .parse() @@ -3834,7 +3877,7 @@ async fn set_user_preferences_accepts_admin_scoped_csrf_from_admin_panel() -> an async fn preferences_theme_cookie_drives_rendered_theme_after_reload() -> anyhow::Result<()> { let state = crate::test_support::app_state(); install_preference_test_themes(); - seed_board_with_thread(&state, "tech", "op")?; + seed_board_with_thread(&state, "tech", "op").map(|_completed_value| ())?; let router = Router::new() .route("/preferences", post(super::set_user_preferences)) .route("/{board}", get(super::board_index)) @@ -3977,7 +4020,7 @@ async fn partial_preference_updates_preserve_unrelated_cookies() -> anyhow::Resu async fn user_theme_overrides_configured_default_and_changes_etag() -> anyhow::Result<()> { let state = crate::test_support::app_state(); install_preference_test_themes(); - seed_board_with_thread(&state, "tech", "op")?; + seed_board_with_thread(&state, "tech", "op").map(|_completed_value| ())?; crate::templates::set_live_default_theme("forest"); let router = activity_router(state); @@ -4119,14 +4162,20 @@ async fn preference_specific_html_responses_vary_on_cookie() -> anyhow::Result<( async fn thread_updates_nav_uses_cookie_preferences() -> anyhow::Result<()> { let state = crate::test_support::app_state(); let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "tech", "Tech", "", false).context("create sfw board")?; - crate::db::create_board(&conn, "x", "Adult", "", true).context("create nsfw board")?; + crate::db::create_board(&conn, "tech", "Tech", "", false) + .context("create sfw board") + .map(|_completed_value| ())?; + crate::db::create_board(&conn, "x", "Adult", "", true) + .context("create nsfw board") + .map(|_completed_value| ())?; drop(conn); let (board_id, thread_id) = seed_board_with_thread(&state, "chat", "op")?; create_reply_on_thread(&state, board_id, thread_id, "reply")?; { - let conn = state.db.get().context("db connection")?; - crate::templates::set_live_boards(crate::db::get_all_boards(&conn).context("load boards")?); + let recovered_conn = state.db.get().context("db connection")?; + crate::templates::set_live_boards( + crate::db::get_all_boards(&recovered_conn).context("load boards")?, + ); } let router = activity_router(state); @@ -4164,12 +4213,14 @@ async fn malformed_board_password_hash_renders_misconfiguration_message() -> any let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "broken", "Broken", "", false).context("create board")?; + crate::db::create_board(&conn, "broken", "Broken", "", false) + .context("create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'broken'", rusqlite::params!["view_password", "not-a-phc-string"], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -4211,14 +4262,16 @@ async fn unlock_board_access_rate_limits_repeated_failures() -> anyhow::Result<( let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "srate", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "srate", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'srate'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -4282,14 +4335,16 @@ async fn locked_board_media_requires_unlock() -> anyhow::Result<()> { let state = crate::test_support::app_state(); { let conn = state.db.get().context("db connection")?; - crate::db::create_board(&conn, "secret", "Secret", "", false).context("create board")?; + crate::db::create_board(&conn, "secret", "Secret", "", false) + .context("create board") + .map(|_completed_value| ())?; let password_hash = crate::utils::crypto::hash_password("swordfish").context("hash password")?; conn.execute( "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE short_name = 'secret'", rusqlite::params!["view_password", password_hash], ) - .context("update board access")?; + .context("update board access").map(|_completed_value| ())?; } let router = Router::new() @@ -4322,7 +4377,8 @@ async fn submit_appeal_is_rate_limited_to_one_open_window() -> anyhow::Result<() "test ban", None, ) - .context("add ban")?; + .context("add ban") + .map(|_completed_value| ())?; } let router = Router::new() diff --git a/src/handlers/captcha.rs b/src/handlers/captcha.rs index 10667626..2cbb923c 100644 --- a/src/handlers/captcha.rs +++ b/src/handlers/captcha.rs @@ -28,13 +28,14 @@ pub(in crate::server) async fn serve_captcha_image( )?; let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png")); - headers.insert( + let _previous_value = + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png")); + drop(headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, max-age=0"), - ); - headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); - headers.insert(header::EXPIRES, HeaderValue::from_static("0")); + )); + drop(headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"))); + drop(headers.insert(header::EXPIRES, HeaderValue::from_static("0"))); Ok((StatusCode::OK, headers, png).into_response()) } diff --git a/src/handlers/favicon.rs b/src/handlers/favicon.rs index 39ebba32..1f84cf8a 100644 --- a/src/handlers/favicon.rs +++ b/src/handlers/favicon.rs @@ -37,7 +37,7 @@ async fn serve_named_global_favicon( |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), |resp| { let mut resp = resp.map(axum::body::Body::new); - resp.headers_mut().insert( + let _previous_value = resp.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static(favicon_content_type(file_name)), ); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 26d7cca8..76ae5ac8 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -615,7 +615,10 @@ async fn discard_rejected_upload_field( budget: &mut PublicMultipartBudget, initial_excess_bytes: usize, ) -> bool { - let deadline = tokio::time::Instant::now() + REJECTED_UPLOAD_DISCARD_TIMEOUT; + let Some(deadline) = tokio::time::Instant::now().checked_add(REJECTED_UPLOAD_DISCARD_TIMEOUT) + else { + return false; + }; budget.rejected_upload = Some(RejectedUploadRecovery { bytes_seen: initial_excess_bytes, deadline, @@ -749,7 +752,7 @@ async fn read_upload_field( let submitted_filename = field.file_name().map(str::to_owned); let fname = submitted_filename .as_deref() - .filter(|name| !name.is_empty()) + .filter(|filename| !filename.is_empty()) .unwrap_or(default_name) .to_owned(); let upload = stream_field_to_temp_file( @@ -764,7 +767,7 @@ async fn read_upload_field( if upload.size_bytes == 0 { if submitted_filename .as_deref() - .is_some_and(|name| !name.is_empty()) + .is_some_and(|filename| !filename.is_empty()) { return Err(AppError::BadRequest("Uploaded file is empty.".into())); } @@ -798,7 +801,7 @@ pub(crate) struct PostFormData { // Poll fields are used only when creating a new thread. pub poll_question: String, pub poll_options: Vec, - /// Duration in seconds (parsed from value + unit) + /// Duration in seconds (parsed from value + unit). pub poll_duration_secs: Option, /// Sage — when true the reply must not bump the thread. pub sage: bool, @@ -832,7 +835,7 @@ pub(crate) fn native_post_error_response( false, "/", ); - response + let _previous_value = response .extensions_mut() .insert(crate::error::ErrorPage::Content { title: "Posting error".into(), @@ -937,7 +940,9 @@ async fn recover_rejected_post_tail( capture_post_draft(&name, &value, draft)?; } "file" | "audio_file" | "image_file" => { - draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + draft.had_attachments |= field + .file_name() + .is_some_and(|filename| !filename.is_empty()); loop { let next_chunk = field.chunk().await.map_err(|error| { multipart_read_error("rejected upload tail", &error) @@ -1109,7 +1114,9 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'file'.".into(), )); } - draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + draft.had_attachments |= field + .file_name() + .is_some_and(|filename| !filename.is_empty()); let upload_result = read_upload_field( field, max_image_size @@ -1134,7 +1141,9 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'audio_file'.".into(), )); } - draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + draft.had_attachments |= field + .file_name() + .is_some_and(|filename| !filename.is_empty()); let upload_result = read_upload_field( field, max_audio_size, @@ -1156,7 +1165,9 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'image_file'.".into(), )); } - draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + draft.had_attachments |= field + .file_name() + .is_some_and(|filename| !filename.is_empty()); let upload_result = read_upload_field( field, max_image_size, @@ -1230,7 +1241,7 @@ pub(crate) async fn parse_post_multipart( /// • "Insufficient disk space" → 413 `UploadTooLarge` /// • "File type not allowed" → 415 `InvalidMediaType` /// • "Not an audio file" → 415 `InvalidMediaType` -/// • anything else → 400 `BadRequest` +/// • anything else → 400 `BadRequest`. pub(crate) fn classify_upload_error(e: &anyhow::Error) -> AppError { let msg = e.to_string(); // Compare lower-cased so minor wording changes in save_upload don't silently @@ -1770,19 +1781,23 @@ mod tests { ); ensure!(budget.bytes_seen == 2048); - let (boundary, body) = + let (response_boundary, resolved_body) = multipart_body_with_files(&[], &[("file", "oversized.png", &[1; 2048], "image/png")]); - let mut multipart = multipart_from_bytes(&boundary, body).await?; - let mut field = multipart + let mut resolved_multipart = + multipart_from_bytes(&response_boundary, resolved_body).await?; + let mut resolved_field = resolved_multipart .next_field() .await? .context("missing upload field")?; - let mut budget = super::PublicMultipartBudget { + let mut resolved_budget = super::PublicMultipartBudget { fields_seen: 0, bytes_seen: super::PUBLIC_MULTIPART_AGGREGATE_MAX_BYTES, ..super::PublicMultipartBudget::default() }; - ensure!(!super::discard_rejected_upload_field(&mut field, &mut budget, 0).await); + ensure!( + !super::discard_rejected_upload_field(&mut resolved_field, &mut resolved_budget, 0) + .await + ); Ok(()) } @@ -2049,7 +2064,8 @@ mod tests { )", [], ) - .context("create file_hashes table")?; + .context("create file_hashes table") + .map(|_completed_value| ())?; Ok(()) } @@ -2099,7 +2115,8 @@ mod tests { draft: &mut crate::templates::forms::PostFormState::default(), }, ) - .await?; + .await + .map(|_completed_value| ())?; Ok("ok") } @@ -2191,7 +2208,8 @@ mod tests { draft: &mut crate::templates::forms::PostFormState::default(), }, ) - .await?; + .await + .map(|_completed_value| ())?; Ok("ok") } @@ -2408,26 +2426,26 @@ mod tests { ensure!(response.status() == StatusCode::OK); let over_pdf = vec![b'p'; 2_049]; - let (boundary, body) = multipart_body_with_files( + let (response_boundary, resolved_body) = multipart_body_with_files( &[("_csrf", "csrf123"), ("body", "pdf")], &[("file", "over.pdf", &over_pdf, "application/pdf")], ); - let response = router + let resolved_response = router .oneshot( Request::builder() .method("POST") .uri("/parse") .header( header::CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), + format!("multipart/form-data; boundary={response_boundary}"), ) - .body(Body::from(body)) + .body(Body::from(resolved_body)) .context("build over-limit PDF multipart request")?, ) .await .context("receive over-limit PDF multipart response")?; - ensure!(response.status() == StatusCode::PAYLOAD_TOO_LARGE); + ensure!(resolved_response.status() == StatusCode::PAYLOAD_TOO_LARGE); Ok(()) } @@ -2466,9 +2484,9 @@ mod tests { } ensure!(budget.note_field().is_err()); - let mut budget = super::PublicMultipartBudget::default(); - budget.note_chunk(super::PUBLIC_MULTIPART_AGGREGATE_MAX_BYTES)?; - ensure!(budget.note_chunk(1).is_err()); + let mut resolved_budget = super::PublicMultipartBudget::default(); + resolved_budget.note_chunk(super::PUBLIC_MULTIPART_AGGREGATE_MAX_BYTES)?; + ensure!(resolved_budget.note_chunk(1).is_err()); Ok(()) } @@ -2487,7 +2505,7 @@ mod tests { None, Some(other), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), boards_dir .path() .to_str() @@ -2549,7 +2567,7 @@ mod tests { let result = super::process_primary_upload( Some(upload), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() @@ -2614,7 +2632,7 @@ mod tests { let (uploaded, primary_hash) = super::process_primary_upload( Some(temp_upload("doc.pdf", pdf)?), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() @@ -2654,7 +2672,7 @@ mod tests { let result = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() @@ -2696,7 +2714,7 @@ mod tests { let result = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() @@ -2734,7 +2752,7 @@ mod tests { let result = super::process_primary_upload( Some(temp_upload("not-really.pdf", b"plain text")?), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() @@ -2776,7 +2794,7 @@ mod tests { let (uploaded, _) = super::process_primary_upload( Some(temp_upload("doc.pdf", valid_pdf())?), &board, - |hash| Ok(crate::db::find_file_by_hash(&conn, hash)?), + |candidate_hash| Ok(crate::db::find_file_by_hash(&conn, candidate_hash)?), uploads_dir .path() .to_str() diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index 2578aa8b..5b8de2d0 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -205,7 +205,7 @@ fn build_upload_finalize_payload( relative_paths.dedup(); for relative_path in &relative_paths { - artifact_sha256.insert( + let _previous_value = artifact_sha256.insert( relative_path.clone(), crate::pending_fs::staged_upload_artifact_sha256(stage_dir, relative_path, "required") .map_err(AppError::Internal)?, @@ -218,7 +218,7 @@ fn build_upload_finalize_payload( match std::fs::symlink_metadata(&staged_path) { Ok(metadata) if metadata.file_type().is_file() => { optional_paths.push(file.thumb_path.clone()); - artifact_sha256.insert( + let _previous_value = artifact_sha256.insert( file.thumb_path.clone(), crate::pending_fs::staged_upload_artifact_sha256( stage_dir, @@ -662,7 +662,12 @@ fn submit_post_with_preparation( Some((*thread_id, *sage)) } - SubmitPostMode::NewThread { .. } => None, + SubmitPostMode::NewThread { + subject: _, + poll_question: _, + poll_options: _, + poll_duration_secs: _, + } => None, }; let ip_hash = hash_ip(&identity_key, &cookie_secret); @@ -739,9 +744,9 @@ fn submit_post_with_preparation( .map(|upload| upload.file_path.as_str()) .collect(); - let validate = |conn: &rusqlite::Connection| { + let validate = |validation_conn: &rusqlite::Connection| { revalidate_posting_policy( - conn, + validation_conn, &board, &filters, admin_session_id.as_deref(), @@ -807,9 +812,9 @@ fn submit_post_with_preparation( expires_at, }) }; - let conn = checkout_post_connection(pool, &uploads, &upload_dir)?; + let recovered_conn = checkout_post_connection(pool, &uploads, &upload_dir)?; let create_result = db::threads::create_thread_submission( - &conn, + &recovered_conn, board.id, subject.as_deref(), &new_post, @@ -824,17 +829,18 @@ fn submit_post_with_preparation( ); let (thread_id, post_id, _) = match create_result { Ok(db::threads::PostCreationOutcome::Created(ids)) => { - drop(conn); + drop(recovered_conn); ids } Ok(db::threads::PostCreationOutcome::Replayed(existing)) => { - let result = existing_submission_result(&conn, board.short_name, existing); - drop(conn); + let result = + existing_submission_result(&recovered_conn, board.short_name, existing); + drop(recovered_conn); uploads.rollback_new_files(pool, &upload_dir)?; return result; } Err(error) => { - drop(conn); + drop(recovered_conn); uploads.rollback_new_files(pool, &upload_dir)?; return Err(post_creation_error(error)); } @@ -846,7 +852,10 @@ fn submit_post_with_preparation( Some(board.id), ) } - SubmitPostMode::Reply { .. } => { + SubmitPostMode::Reply { + thread_id: _, + sage: _, + } => { let (thread_id, sage) = reply_context .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Missing reply context")))?; let should_bump = !sage; @@ -863,9 +872,9 @@ fn submit_post_with_preparation( deletion_token.clone(), false, ); - let conn = checkout_post_connection(pool, &uploads, &upload_dir)?; + let recovered_conn = checkout_post_connection(pool, &uploads, &upload_dir)?; let post_id = match db::threads::create_reply_submission( - &conn, + &recovered_conn, &new_post, &submission_token, should_bump, @@ -877,17 +886,18 @@ fn submit_post_with_preparation( ), ) { Ok(db::threads::PostCreationOutcome::Created(post_id)) => { - drop(conn); + drop(recovered_conn); post_id } Ok(db::threads::PostCreationOutcome::Replayed(existing)) => { - let result = existing_submission_result(&conn, board.short_name, existing); - drop(conn); + let result = + existing_submission_result(&recovered_conn, board.short_name, existing); + drop(recovered_conn); uploads.rollback_new_files(pool, &upload_dir)?; return result; } Err(error) => { - drop(conn); + drop(recovered_conn); uploads.rollback_new_files(pool, &upload_dir)?; if let Some(closed) = error.downcast_ref::() { return Err(AppError::Forbidden(closed.message().to_owned())); @@ -915,8 +925,8 @@ fn submit_post_with_preparation( &board.short_name, )?; if let Some(prune_board_id) = prune_board_id { - let conn = pool.get()?; - if let Err(error) = job_queue.notify_persisted_thread_prune(&conn) { + let recovered_conn = pool.get()?; + if let Err(error) = job_queue.notify_persisted_thread_prune(&recovered_conn) { tracing::warn!( target: "workers", board = %board.short_name, @@ -943,8 +953,8 @@ fn submit_post_with_preparation( ); } - let conn = pool.get()?; - let stored_post = db::get_post(&conn, post_id)? + let recovered_conn = pool.get()?; + let stored_post = db::get_post(&recovered_conn, post_id)? .ok_or_else(|| AppError::NotFound("Posted row not found.".into()))?; Ok(SubmitPostResult { @@ -1217,16 +1227,19 @@ mod tests { let pool = state.db.clone(); let job_queue = std::sync::Arc::clone(&job_queue); handles.push(scope.spawn(move || { - barrier.wait(); + let _completed_value = barrier.wait(); submit_post(&pool, job_queue.as_ref(), command) })); } let mut results = Vec::with_capacity(handles.len()); for handle in handles { - let result = handle - .join() - .map_err(|_| anyhow::anyhow!("concurrent submission worker panicked"))?; + let result = handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "concurrent submission worker panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })?; results.push(result.context("concurrent submission failed")?); } Ok(results) @@ -1301,7 +1314,7 @@ mod tests { if path.is_dir() { visit(&path, files)?; } else if path.is_file() { - files.insert(path); + let _completed_value = files.insert(path); } } Ok(()) @@ -1321,11 +1334,13 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let ip_hash = crate::utils::crypto::hash_ip(TEST_IDENTITY_KEY, TEST_COOKIE_SECRET); crate::db::add_ban(&conn, &ip_hash, "posting blocked", None) - .context("failed to add ban")?; + .context("failed to add ban") + .map(|_completed_value| ())?; let error = match submit_post( &state.db, @@ -1352,7 +1367,15 @@ mod tests { assert_eq!(reason, "posting blocked"); assert_eq!(csrf_token, "ban-csrf"); } - other => bail!("expected BannedUser, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::BadRequest(_) + | AppError::Forbidden(_) + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BannedUser, got {other:?}"), } let thread_count: i64 = conn @@ -1376,7 +1399,8 @@ mod tests { "UPDATE boards SET post_cooldown_secs = 60 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to enable cooldown")?; + .context("failed to enable cooldown") + .map(|_completed_value| ())?; let ip_hash = crate::utils::crypto::hash_ip(TEST_IDENTITY_KEY, TEST_COOKIE_SECRET); let (thread_id, _, _) = crate::db::create_thread_with_optional_poll( @@ -1413,7 +1437,18 @@ mod tests { assert!(message.contains("Please wait")); assert!(message.contains("before posting again.")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1427,12 +1462,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_captcha = 1 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to enable CAPTCHA")?; + .context("failed to enable CAPTCHA") + .map(|_completed_value| ())?; let error = match submit_post( &state.db, @@ -1458,7 +1495,18 @@ mod tests { "CAPTCHA verification failed. Enter the text from the image and try again." ); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1472,12 +1520,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_captcha = 1 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to enable CAPTCHA")?; + .context("failed to enable CAPTCHA") + .map(|_completed_value| ())?; let captcha_id = "00000000000000000000000000000007"; crate::captcha::testing::insert_challenge_for_test( TEST_BOARD, @@ -1514,9 +1564,9 @@ mod tests { replay.captcha_id = captcha_id.to_owned(); replay.captcha_answer = "ABC23".to_owned(); let error = match submit_post(&state.db, state.job_queue.as_ref(), replay) { - Ok(result) => bail!( + Ok(replay_result) => bail!( "expected captcha replay rejection, got {}", - result.redirect_url + replay_result.redirect_url ), Err(error) => error, }; @@ -1533,12 +1583,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_captcha = 1 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to enable CAPTCHA")?; + .context("failed to enable CAPTCHA") + .map(|_completed_value| ())?; let captcha_id = "00000000000000000000000000000008"; crate::captcha::testing::insert_challenge_for_test( TEST_BOARD, @@ -1578,7 +1630,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let error = match submit_post( &state.db, @@ -1607,7 +1660,18 @@ mod tests { AppError::BadRequest(message) => { assert_eq!(message, "Polls need a question and at least two options."); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } Ok(()) } @@ -1620,7 +1684,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let board = crate::db::get_board_by_short(&conn, TEST_BOARD) .context("failed to load board")? .context("test board did not exist")?; @@ -1662,12 +1727,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET max_image_size = ?1 WHERE short_name = ?2", rusqlite::params![64_i64, TEST_BOARD], ) - .context("failed to shrink image limit")?; + .context("failed to shrink image limit") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "board-image-cap", @@ -1691,7 +1758,18 @@ mod tests { AppError::UploadTooLarge(message) => { assert!(message.contains("Maximum image upload size is 64 B.")); } - other => bail!("expected UploadTooLarge, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::BadRequest(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected UploadTooLarge, got {other:?}"), } Ok(()) } @@ -1705,7 +1783,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "audio-disabled", @@ -1729,7 +1808,18 @@ mod tests { AppError::BadRequest(message) => { assert!(message.contains("Audio uploads are disabled")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); assert!(!upload_dir.path().join(TEST_BOARD).exists()); @@ -1746,12 +1836,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_audio = 1 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to enable audio")?; + .context("failed to enable audio") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "malformed-aac", @@ -1776,7 +1868,18 @@ mod tests { || message.contains("could not validate") ); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); assert!(!upload_dir.path().join(TEST_BOARD).exists()); @@ -1797,12 +1900,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_video = 0 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to disable video")?; + .context("failed to disable video") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "video-disabled", @@ -1826,7 +1931,18 @@ mod tests { AppError::BadRequest(message) => { assert!(message.contains("Video uploads are disabled")); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); assert!(!upload_dir.path().join(TEST_BOARD).exists()); @@ -1843,12 +1959,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET allow_audio = 1, max_audio_size = 8 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to shrink audio limit")?; + .context("failed to shrink audio limit") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "audio-overlimit", @@ -1872,7 +1990,18 @@ mod tests { AppError::UploadTooLarge(message) => { assert!(message.contains("Maximum audio upload size is 8 B.")); } - other => bail!("expected UploadTooLarge, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::BadRequest(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected UploadTooLarge, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); assert!(!upload_dir.path().join(TEST_BOARD).exists()); @@ -1889,12 +2018,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET max_video_size = 8 WHERE short_name = ?1", rusqlite::params![TEST_BOARD], ) - .context("failed to shrink video limit")?; + .context("failed to shrink video limit") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "video-overlimit", @@ -1918,7 +2049,18 @@ mod tests { AppError::UploadTooLarge(message) => { assert!(message.contains("Maximum video upload size is 8 B.")); } - other => bail!("expected UploadTooLarge, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::BadRequest(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected UploadTooLarge, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); assert!(!upload_dir.path().join(TEST_BOARD).exists()); @@ -1935,7 +2077,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let mut command = thread_command_with_poll( TEST_BOARD, @@ -1963,7 +2106,18 @@ mod tests { AppError::BadRequest(message) => { assert_eq!(message, "Polls need a question and at least two options."); } - other => bail!("expected BadRequest, got {other:?}"), + other @ (AppError::NotFound(_) + | AppError::Forbidden(_) + | AppError::BannedUser { + reason: _, + csrf_token: _, + } + | AppError::UploadTooLarge(_) + | AppError::InvalidMediaType(_) + | AppError::Conflict(_) + | AppError::DbBusy + | AppError::Internal(_) + | AppError::Tls(_)) => bail!("expected BadRequest, got {other:?}"), } assert_eq!(pending_upload_stage_count(upload_dir.path())?, 0); @@ -1984,12 +2138,14 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; conn.execute( "UPDATE boards SET max_image_size = ?1 WHERE short_name = ?2", rusqlite::params![1024 * 1024_i64, TEST_BOARD], ) - .context("failed to raise image limit")?; + .context("failed to raise image limit") + .map(|_completed_value| ())?; let mut command = thread_command( TEST_BOARD, "board-image-raised-cap", @@ -2016,7 +2172,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; let board = crate::db::get_board_by_short(&conn, TEST_BOARD) .context("failed to load board")? .context("test board did not exist")?; @@ -2112,7 +2269,7 @@ mod tests { fn thumbnail_failures_preserve_original_and_omit_derived_intent_metadata() -> Result<()> { let state = crate::test_support::app_state(); let conn = state.db.get().context("get test database connection")?; - crate::db::create_board(&conn, TEST_BOARD, "Test", "", false)?; + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).map(|_completed_value| ())?; let board = crate::db::get_board_by_short(&conn, TEST_BOARD)? .context("test board did not exist")?; let upload_dir = tempfile::tempdir().context("create upload directory")?; @@ -2185,7 +2342,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; submit_post( &state.db, @@ -2200,7 +2358,8 @@ mod tests { .context("upload directory was not valid UTF-8")?, ), ) - .context("failed to make first submission")?; + .context("failed to make first submission") + .map(|_completed_value| ())?; let original_created_at = chrono::Utc::now().timestamp() - crate::handlers::board::self_action_window_secs() - 1; @@ -2208,7 +2367,8 @@ mod tests { "UPDATE posts SET created_at = ?1", rusqlite::params![original_created_at], ) - .context("failed to age original post")?; + .context("failed to age original post") + .map(|_completed_value| ())?; let duplicate = submit_post( &state.db, @@ -2292,7 +2452,8 @@ mod tests { .context("upload directory was not valid UTF-8")?, ), ) - .context("failed to create first reply")?; + .context("failed to create first reply") + .map(|_completed_value| ())?; let duplicate = submit_post( &state.db, @@ -2338,7 +2499,7 @@ mod tests { #[test] fn concurrent_thread_submissions_share_one_atomic_result_on_fresh_databases() -> Result<()> { - for repetition in 0..3 { + for repetition in 0_i32..3_i32 { let state = crate::test_support::app_state(); let upload_dir = tempfile::tempdir().context("failed to create upload directory")?; let conn = state @@ -2346,7 +2507,8 @@ mod tests { .get() .context("failed to get setup database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; drop(conn); let token = format!("thread-race-{repetition}"); @@ -2355,7 +2517,7 @@ mod tests { .path() .to_str() .context("upload directory was not valid UTF-8")?; - let commands = (0..8) + let commands = (0_i32..8_i32) .map(|_| thread_command(TEST_BOARD, &token, &body, upload_path)) .collect(); let results = submit_concurrently(&state, commands)?; @@ -2371,23 +2533,23 @@ mod tests { assert_eq!(canonical_locations.len(), 1); assert_eq!(canonical_posts.len(), 1); - let conn = state + let recovered_conn = state .db .get() .context("failed to get verification database connection")?; - let post_count: i64 = conn.query_row( + let post_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM posts WHERE body = ?1", rusqlite::params![body], |row| row.get(0), )?; - let thread_count: i64 = conn.query_row( + let thread_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM threads WHERE board_id = ( SELECT id FROM boards WHERE short_name = ?1 )", rusqlite::params![TEST_BOARD], |row| row.get(0), )?; - let token_count: i64 = conn.query_row( + let token_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM post_submissions WHERE submission_token = ?1", rusqlite::params![token], |row| row.get(0), @@ -2395,14 +2557,14 @@ mod tests { assert_eq!(post_count, 1); assert_eq!(thread_count, 1); assert_eq!(token_count, 1); - assert_database_integrity(&conn, upload_dir.path())?; + assert_database_integrity(&recovered_conn, upload_dir.path())?; } Ok(()) } #[test] fn concurrent_reply_submissions_share_one_atomic_result_on_fresh_databases() -> Result<()> { - for repetition in 0..3 { + for repetition in 0_i32..3_i32 { let state = crate::test_support::app_state(); let upload_dir = tempfile::tempdir().context("failed to create upload directory")?; let conn = state @@ -2428,7 +2590,7 @@ mod tests { .path() .to_str() .context("upload directory was not valid UTF-8")?; - let commands = (0..8) + let commands = (0_i32..8_i32) .map(|_| reply_command(TEST_BOARD, thread_id, &token, &body, upload_path)) .collect(); let results = submit_concurrently(&state, commands)?; @@ -2444,21 +2606,21 @@ mod tests { assert_eq!(canonical_locations.len(), 1); assert_eq!(canonical_posts.len(), 1); - let conn = state + let recovered_conn = state .db .get() .context("failed to get verification database connection")?; - let reply_count: i64 = conn.query_row( + let reply_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM posts WHERE thread_id = ?1 AND is_op = 0", rusqlite::params![thread_id], |row| row.get(0), )?; - let stored_reply_count: i64 = conn.query_row( + let stored_reply_count: i64 = recovered_conn.query_row( "SELECT reply_count FROM threads WHERE id = ?1", rusqlite::params![thread_id], |row| row.get(0), )?; - let token_count: i64 = conn.query_row( + let token_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM post_submissions WHERE submission_token = ?1", rusqlite::params![token], |row| row.get(0), @@ -2466,7 +2628,7 @@ mod tests { assert_eq!(reply_count, 1); assert_eq!(stored_reply_count, 1); assert_eq!(token_count, 1); - assert_database_integrity(&conn, upload_dir.path())?; + assert_database_integrity(&recovered_conn, upload_dir.path())?; } Ok(()) } @@ -2480,7 +2642,8 @@ mod tests { .get() .context("failed to get setup database connection")?; crate::db::create_board(&conn, TEST_BOARD, "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; drop(conn); let png = one_pixel_png()?; @@ -2488,7 +2651,7 @@ mod tests { .path() .to_str() .context("upload directory was not valid UTF-8")?; - let commands = (0..8) + let commands = (0_i32..8_i32) .map(|index| { let mut command = thread_command( TEST_BOARD, @@ -2510,28 +2673,28 @@ mod tests { 1 ); - let conn = state + let recovered_conn = state .db .get() .context("failed to get verification database connection")?; - let (file_path, thumb_path): (String, String) = conn.query_row( + let (file_path, thumb_path): (String, String) = recovered_conn.query_row( "SELECT file_path, thumb_path FROM posts WHERE body = 'thread media race body'", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; - let post_count: i64 = conn.query_row( + let post_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM posts WHERE body = 'thread media race body'", [], |row| row.get(0), )?; - let token_count: i64 = conn.query_row( + let token_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM post_submissions WHERE submission_token = 'thread-media-race'", [], |row| row.get(0), )?; let file_hash_count: i64 = - conn.query_row("SELECT COUNT(*) FROM file_hashes", [], |row| row.get(0))?; + recovered_conn.query_row("SELECT COUNT(*) FROM file_hashes", [], |row| row.get(0))?; assert_eq!(post_count, 1); assert_eq!(token_count, 1); assert_eq!(file_hash_count, 1); @@ -2542,7 +2705,7 @@ mod tests { .map(|path| upload_dir.path().join(path)) .collect::>(); assert_eq!(regular_files(upload_dir.path())?, expected_files); - assert_database_integrity(&conn, upload_dir.path())?; + assert_database_integrity(&recovered_conn, upload_dir.path())?; Ok(()) } @@ -2591,7 +2754,7 @@ mod tests { let distinct_threads = submit_concurrently( &state, - (0..8) + (0_i32..8_i32) .map(|index| { thread_command( TEST_BOARD, @@ -2611,12 +2774,12 @@ mod tests { 8 ); - let conn = state + let retry_conn = state .db .get() .context("failed to get reply setup database connection")?; let (reply_thread_id, _, _) = crate::db::create_thread_with_optional_poll( - &conn, + &retry_conn, board_id, Some("reply target"), &sample_post( @@ -2654,11 +2817,11 @@ mod tests { )?; assert_eq!(first_reply.redirect_url, replayed_reply.redirect_url); assert_eq!(first_reply.post_id, replayed_reply.post_id); - drop(conn); + drop(retry_conn); let distinct_replies = submit_concurrently( &state, - (0..8) + (0_i32..8_i32) .map(|index| { reply_command( TEST_BOARD, @@ -2679,11 +2842,11 @@ mod tests { 8 ); - let conn = state + let recovered_conn = state .db .get() .context("failed to get rollback database connection")?; - conn.execute_batch( + recovered_conn.execute_batch( "CREATE TRIGGER fail_submission_post BEFORE INSERT ON posts WHEN NEW.body IN ('force thread rollback', 'force reply rollback') @@ -2714,14 +2877,14 @@ mod tests { ), ); assert!(failed_reply.is_err()); - let failed_token_count: i64 = conn.query_row( + let failed_token_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM post_submissions WHERE submission_token IN ('rollback-thread', 'rollback-reply')", [], |row| row.get(0), )?; assert_eq!(failed_token_count, 0); - conn.execute_batch("DROP TRIGGER fail_submission_post")?; + recovered_conn.execute_batch("DROP TRIGGER fail_submission_post")?; let corrected_thread = submit_post( &state.db, @@ -2747,17 +2910,17 @@ mod tests { assert!(corrected_thread.post_id > 0); assert!(corrected_reply.post_id > 0); - let distinct_thread_count: i64 = conn.query_row( + let distinct_thread_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM posts WHERE body LIKE 'distinct thread body %'", [], |row| row.get(0), )?; - let distinct_reply_count: i64 = conn.query_row( + let distinct_reply_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM posts WHERE body LIKE 'distinct reply body %'", [], |row| row.get(0), )?; - let corrected_token_count: i64 = conn.query_row( + let corrected_token_count: i64 = recovered_conn.query_row( "SELECT COUNT(*) FROM post_submissions WHERE submission_token IN ('rollback-thread', 'rollback-reply')", [], @@ -2766,7 +2929,7 @@ mod tests { assert_eq!(distinct_thread_count, 8); assert_eq!(distinct_reply_count, 8); assert_eq!(corrected_token_count, 2); - assert_database_integrity(&conn, upload_dir.path())?; + assert_database_integrity(&recovered_conn, upload_dir.path())?; Ok(()) } @@ -2792,7 +2955,8 @@ mod tests { fn independent_preparation_releases_the_only_pool_connection() -> Result<()> { let state = single_connection_state()?; let uploads = tempfile::tempdir()?; - crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false)?; + crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false) + .map(|_completed_value| ())?; let command = thread_command( TEST_BOARD, "single-pool-post", @@ -2846,24 +3010,27 @@ mod tests { ); let result = super::submit_post_with_preparation(&state.db, &state.job_queue, command, || { - let conn = state.db.try_get().context("preparation held the pool")?; + let recovered_conn = state.db.try_get().context("preparation held the pool")?; if archived { - crate::db::set_thread_archived(&conn, thread, true)?; + crate::db::set_thread_archived(&recovered_conn, thread, true)?; } else { - crate::db::set_thread_locked(&conn, thread, true)?; + crate::db::set_thread_locked(&recovered_conn, thread, true)?; } Ok(()) }); assert!(matches!(result, Err(AppError::Forbidden(_)))); - let conn = state.db.get()?; - assert_eq!(crate::db::get_posts_for_thread(&conn, thread)?.len(), 1); + let recovered_conn = state.db.get()?; + assert_eq!( + crate::db::get_posts_for_thread(&recovered_conn, thread)?.len(), + 1 + ); assert_eq!( - crate::db::get_thread(&conn, thread)? + crate::db::get_thread(&recovered_conn, thread)? .context("thread")? .reply_count, 0 ); - assert!(conn.is_autocommit()); + assert!(recovered_conn.is_autocommit()); } Ok(()) } @@ -2873,7 +3040,8 @@ mod tests { for mutation in ["access", "media", "ban", "captcha", "cooldown", "filters"] { let state = single_connection_state()?; let uploads = tempfile::tempdir()?; - crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false)?; + crate::db::create_board(&*state.db.get()?, TEST_BOARD, "Test", "", false) + .map(|_completed_value| ())?; let command = thread_command( TEST_BOARD, "policy-race", @@ -2888,21 +3056,24 @@ mod tests { let conn = state.db.try_get().context("preparation held the pool")?; match mutation { "access" => { - conn.execute("UPDATE boards SET access_mode='post_password',access_password_hash='changed'", [])?; + conn.execute("UPDATE boards SET access_mode='post_password',access_password_hash='changed'", []).map(|_completed_value| ())?; } "media" => { - conn.execute("UPDATE boards SET allow_images=0", [])?; + conn.execute("UPDATE boards SET allow_images=0", []) + .map(|_completed_value| ())?; } "captcha" => { - conn.execute("UPDATE boards SET allow_captcha=1", [])?; + conn.execute("UPDATE boards SET allow_captcha=1", []) + .map(|_completed_value| ())?; } "filters" => { - conn.execute("INSERT INTO word_filters(pattern,replacement) VALUES('body','filtered')", [])?; + conn.execute("INSERT INTO word_filters(pattern,replacement) VALUES('body','filtered')", []).map(|_completed_value| ())?; } "cooldown" => { let board = crate::db::get_board_by_short(&conn, TEST_BOARD)? .context("board")?; - conn.execute("UPDATE boards SET post_cooldown_secs=60", [])?; + conn.execute("UPDATE boards SET post_cooldown_secs=60", []) + .map(|_completed_value| ())?; crate::db::create_thread_with_optional_poll( &conn, board.id, @@ -2920,7 +3091,8 @@ mod tests { "", None, None, - )?; + ) + .map(|_completed_value| ())?; } _ => { crate::db::add_ban( @@ -2931,7 +3103,8 @@ mod tests { ), "new ban", None, - )?; + ) + .map(|_completed_value| ())?; } } Ok(()) @@ -2958,13 +3131,14 @@ mod tests { let state = crate::test_support::app_state(); let uploads = tempfile::tempdir()?; let conn = state.db.get()?; - crate::db::create_board(&conn, TEST_BOARD, "Test", "", false)?; - conn.execute("UPDATE boards SET post_cooldown_secs=60", [])?; + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).map(|_completed_value| ())?; + conn.execute("UPDATE boards SET post_cooldown_secs=60", []) + .map(|_completed_value| ())?; drop(conn); let barrier = std::sync::Barrier::new(2); let results = std::thread::scope(|scope| -> Result> { let mut handles = Vec::new(); - for number in 0..2 { + for number in 0_i32..2_i32 { let command = thread_command( TEST_BOARD, &format!("cooldown-{number}"), @@ -2979,7 +3153,7 @@ mod tests { &state.job_queue, command, || { - barrier.wait(); + let _completed_value = barrier.wait(); Ok(()) }, ) @@ -2988,9 +3162,12 @@ mod tests { handles .into_iter() .map(|handle| { - handle - .join() - .map_err(|_| anyhow::anyhow!("posting worker panicked")) + handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "posting worker panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + }) }) .collect() })?; @@ -3002,8 +3179,8 @@ mod tests { .count(), 1 ); - let conn = state.db.get()?; - crate::db::verify_database_schema(&conn)?; + let recovered_conn = state.db.get()?; + crate::db::verify_database_schema(&recovered_conn)?; Ok(()) } } diff --git a/src/handlers/posting/performance.rs b/src/handlers/posting/performance.rs index 9efc6a29..757b6a1d 100644 --- a/src/handlers/posting/performance.rs +++ b/src/handlers/posting/performance.rs @@ -27,7 +27,8 @@ impl r2d2::HandleEvent for PostingPoolProbe { } } fn handle_timeout(&self, _event: r2d2::event::TimeoutEvent) { - self.timeouts + let _previous_count = self + .timeouts .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } } @@ -37,7 +38,13 @@ fn posting_latencies(mut values: Vec) -> serde_json::Value { values.sort_unstable(); let percentile = |percent: usize| { values - .get((values.len() * percent).div_ceil(100).saturating_sub(1)) + .get( + values + .len() + .saturating_mul(percent) + .div_ceil(100) + .saturating_sub(1), + ) .copied() .unwrap_or(0) }; @@ -76,7 +83,8 @@ fn media_posting_case(size: u32, synchronous: &'static str) -> Result Result Result<()> { let mut handles = Vec::new(); let mut commands = commands.into_iter(); - for _ in 0..2 { + for _ in 0_i32..2_i32 { let batch: Vec<_> = commands.by_ref().take(4).collect(); let barrier = &barrier; let remaining = &remaining; @@ -130,21 +138,23 @@ fn run_media_posting_case( std::thread::Builder::new() .name("db-post-benchmark".into()) .spawn_scoped(scope, move || -> Result<()> { - barrier.wait(); + let _completed_value = barrier.wait(); let result = (|| -> Result<()> { for command in batch { - let started = std::time::Instant::now(); - benchmark_submit(&state.db, &state.job_queue, command)?; - post_times.lock().push(started.elapsed().as_micros()); + let post_started = std::time::Instant::now(); + benchmark_submit(&state.db, &state.job_queue, command) + .map(|_posted_identity| ())?; + post_times.lock().push(post_started.elapsed().as_micros()); } Ok(()) })(); - remaining.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + let _previous_count = + remaining.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); result })?, ); } - for _ in 0..4 { + for _ in 0_i32..4_i32 { let barrier = &barrier; let remaining = &remaining; let reads = &reads; @@ -152,11 +162,12 @@ fn run_media_posting_case( std::thread::Builder::new() .name("db-read-benchmark".into()) .spawn_scoped(scope, move || -> Result<()> { - barrier.wait(); + let _completed_value = barrier.wait(); while remaining.load(std::sync::atomic::Ordering::Relaxed) > 0 { if let Ok(conn) = state.db.get() { crate::db::database_ready_probe(&conn)?; - reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _previous_count = + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } std::thread::sleep(std::time::Duration::from_millis(2)); } @@ -165,9 +176,12 @@ fn run_media_posting_case( ); } for handle in handles { - handle - .join() - .map_err(|_| anyhow::anyhow!("media benchmark worker panicked"))??; + handle.join().map_err(|panic_payload| { + anyhow::anyhow!( + "media benchmark worker panicked: {}", + crate::media::process::panic_message(panic_payload.as_ref()) + ) + })??; } Ok(()) })?; diff --git a/src/handlers/render.rs b/src/handlers/render.rs index d5e8eab5..448f361d 100644 --- a/src/handlers/render.rs +++ b/src/handlers/render.rs @@ -121,8 +121,10 @@ pub(super) fn load_board_page_data( // The preview map is consumed here, so move each list out instead // of cloning every preview post. let preview_posts = previews.remove(&thread.id).unwrap_or_default(); - let omitted = - (thread.reply_count - i64::try_from(preview_posts.len()).unwrap_or(0)).max(0); + let omitted = thread + .reply_count + .saturating_sub(i64::try_from(preview_posts.len()).unwrap_or(0)) + .max(0); ThreadSummary { thread, preview_posts, @@ -309,7 +311,7 @@ mod tests { audio_file_name: None, audio_file_size: None, audio_mime_type: None, - created_at: 100 + id, + created_at: 100_i64.saturating_add(id), deletion_token: "token".into(), is_op: id == 1, edited_at: None, @@ -343,7 +345,7 @@ mod tests { vote_count: vote_counts[1], }, ], - total_votes: vote_counts[0] + vote_counts[1], + total_votes: vote_counts[0].saturating_add(vote_counts[1]), user_voted_option, is_expired: false, } diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs index df62617c..83a9cf5e 100644 --- a/src/handlers/setup.rs +++ b/src/handlers/setup.rs @@ -13,7 +13,6 @@ use axum::{ }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use serde::{Deserialize, Serialize}; -use std::fmt::Write as _; /// Setup CSRF scope used by this handler. const SETUP_CSRF_SCOPE: &str = "first-run-setup"; @@ -696,9 +695,11 @@ pub(in crate::server) async fn setup_finish( move || -> Result<()> { let mut conn = pool.get()?; let tx = conn.transaction()?; - db::ensure_setup_available(&tx).map_err(|_setup_state_error| { - AppError::NotFound("Setup wizard is not available.".into()) - })?; + db::ensure_setup_available(&tx) + .map_err(|_setup_state_error| { + AppError::NotFound("Setup wizard is not available.".into()) + }) + .map(|_completed_value| ())?; if db::admin_count(&tx)? == 0 { let username = parsed.admin_username.as_deref().ok_or_else(|| { AppError::BadRequest("Initial admin username is required.".into()) @@ -711,7 +712,7 @@ pub(in crate::server) async fn setup_finish( })?; crypto::hash_password(password)? }; - db::create_admin(&tx, username, &password_hash)?; + db::create_admin(&tx, username, &password_hash).map(|_completed_value| ())?; } if db::board_slug_exists(&tx, &parsed.board_slug)? { return Err(AppError::Conflict(format!( @@ -741,9 +742,9 @@ pub(in crate::server) async fn setup_finish( access_password_hash = '' WHERE id = ?17", rusqlite::params![ - if parsed.allow_posting { 150 } else { 0 }, - 150, - 500, + if parsed.allow_posting { 150_i32 } else { 0_i32 }, + 150_i32, + 500_i32, parsed.image_limit_bytes, parsed.video_limit_bytes, parsed.audio_limit_bytes, @@ -759,7 +760,8 @@ pub(in crate::server) async fn setup_finish( parsed.board_visibility, board_id, ], - )?; + ) + .map(|_completed_value| ())?; db::set_site_setting(&tx, "site_name", &parsed.site_name)?; db::set_site_setting(&tx, "site_subtitle", &parsed.site_subtitle)?; db::set_site_setting(&tx, "default_theme", &parsed.default_theme)?; @@ -918,7 +920,8 @@ pub(in crate::server) async fn admin_close_setup( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref())?; + crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref()) + .map(|_completed_value| ())?; db::close_reopened_setup(&conn)?; Ok(()) } @@ -1020,10 +1023,12 @@ fn setup_form_page( alerts.push_str(r#"
Admin setup session. Setup was reopened by an administrator. Existing credentials will not be replaced.
"#); } if let Some(warning) = transport_warning { - let _ = write!( - alerts, - r#"
{}
"#, - escape_html(warning) + templates::append_html( + &mut alerts, + format_args!( + r#"
{}
"#, + escape_html(warning) + ), ); } if !errors.is_empty() { @@ -1031,7 +1036,7 @@ fn setup_form_page( r#""); } @@ -1242,10 +1247,10 @@ fn setup_review_page( admin = admin_line, slug = escape_html(&parsed.board_slug), board = escape_html(&parsed.board_name), - image = parsed.image_limit_bytes / i64::try_from(MIB).unwrap_or(1), - video = parsed.video_limit_bytes / i64::try_from(MIB).unwrap_or(1), - audio = parsed.audio_limit_bytes / i64::try_from(MIB).unwrap_or(1), - pdf = parsed.pdf_limit_bytes / i64::try_from(MIB).unwrap_or(1), + image = parsed.image_limit_bytes / 1_048_576_i64, + video = parsed.video_limit_bytes / 1_048_576_i64, + audio = parsed.audio_limit_bytes / 1_048_576_i64, + pdf = parsed.pdf_limit_bytes / 1_048_576_i64, tor = if parsed.enable_tor { "enabled" } else { @@ -1297,17 +1302,19 @@ fn preset_options(selected: &str) -> String { SetupPreset::Private => "Restricted board visibility and conservative access.", SetupPreset::Local => "Loopback-focused defaults for local evaluation.", }; - let _ = write!( - out, - r#"
"#, - value = preset.as_str(), - checked = if selected == preset.as_str() { - " checked" - } else { - "" - }, - label = preset.label(), - description = description, + templates::append_html( + &mut out, + format_args!( + r#"

{description}

load defaults
"#, + value = preset.as_str(), + checked = if selected == preset.as_str() { + " checked" + } else { + "" + }, + label = preset.label(), + description = description, + ), ); } out @@ -1320,10 +1327,12 @@ fn visibility_options(selected: &str) -> String { ("view_password", "Require password to view"), ("post_password", "Require password to post"), ] { - let _ = write!( - out, - r#""#, - selected_attr = if selected == value { " selected" } else { "" }, + templates::append_html( + &mut out, + format_args!( + r#""#, + selected_attr = if selected == value { " selected" } else { "" }, + ), ); } out @@ -1443,7 +1452,7 @@ mod tests { use anyhow::{bail, ensure, Context as _, Result as AnyResult}; use axum::{ body::{to_bytes, Body}, - http::{header, Request, StatusCode}, + http::Request, routing::{get, post}, Router, }; @@ -1592,7 +1601,9 @@ mod tests { let state = crate::test_support::app_state(); { let conn = state.db.get().context("get database connection")?; - db::create_admin(&conn, "admin", "hash").context("create admin fixture")?; + db::create_admin(&conn, "admin", "hash") + .context("create admin fixture") + .map(|_completed_value| ())?; } let app = Router::new() .route("/setup", get(setup_get)) @@ -1611,11 +1622,12 @@ mod tests { .context("serve setup request")?; ensure!(response.status() == StatusCode::NOT_FOUND); - let body = to_bytes(response.into_body(), usize::MAX) + let response_body = to_bytes(response.into_body(), usize::MAX) .await .context("read setup response body")?; - let body = String::from_utf8(body.to_vec()).context("decode setup response body")?; - ensure!(body.contains("Setup wizard is not available")); + let response_body = + String::from_utf8(response_body.to_vec()).context("decode setup response body")?; + ensure!(response_body.contains("Setup wizard is not available")); Ok(()) } @@ -1713,13 +1725,14 @@ mod tests { .context("serve setup review request")?; ensure!(response.status() == StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX) + let review_body = to_bytes(response.into_body(), usize::MAX) .await .context("read setup review response")?; - let body = String::from_utf8(body.to_vec()).context("decode setup review response")?; - ensure!(body.contains(r#"data-active-theme="blue-sky""#)); - ensure!(body.contains(r#"data-theme="blue-sky""#)); - ensure!(body.contains(r#"name="default_theme" value="blue-sky""#)); + let review_body = + String::from_utf8(review_body.to_vec()).context("decode setup review response")?; + ensure!(review_body.contains(r#"data-active-theme="blue-sky""#)); + ensure!(review_body.contains(r#"data-theme="blue-sky""#)); + ensure!(review_body.contains(r#"name="default_theme" value="blue-sky""#)); Ok(()) } @@ -1768,7 +1781,8 @@ mod tests { { let conn = state.db.get().context("get database connection")?; db::create_board(&conn, "b", "Existing", "", false) - .context("create conflicting board fixture")?; + .context("create conflicting board fixture") + .map(|_completed_value| ())?; } let app = Router::new() .route("/setup/finish", post(setup_finish)) diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 1b4e4ab7..95d63071 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -101,7 +101,7 @@ pub(in crate::server) async fn view_thread( &CONFIG.cookie_secret, is_admin, )?; - let is_admin = page_data.is_admin; + let effective_is_admin = page_data.is_admin; let thread_badges_enabled = db::get_thread_new_reply_badges_enabled(&conn); let homepage_thread_badges_enabled = db::get_homepage_new_thread_badges_enabled(&conn); let homepage_reply_badges_enabled = db::get_homepage_new_reply_badges_enabled(&conn); @@ -109,7 +109,7 @@ pub(in crate::server) async fn view_thread( Ok(( render::thread_page_etag_signature(&page_data), page_data, - is_admin, + effective_is_admin, thread_badges_enabled, homepage_thread_badges_enabled, homepage_reply_badges_enabled, @@ -215,16 +215,16 @@ pub(in crate::server) async fn view_thread( .status(StatusCode::NOT_MODIFIED) .body(axum::body::Body::empty()) .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - resp.headers_mut().insert( + drop(resp.headers_mut().insert( "etag", HeaderValue::from_str(&etag).unwrap_or_else(|_| HeaderValue::from_static("\"0\"")), - ); - resp.headers_mut().insert( + )); + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::handlers::board::activity_html_cache_control( activity_markers_enabled, )), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); return Ok((jar, resp).into_response()); } @@ -253,14 +253,14 @@ pub(in crate::server) async fn view_thread( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let mut resp = Html(html).into_response(); if let Ok(v) = HeaderValue::from_str(&etag) { - resp.headers_mut().insert("etag", v); + drop(resp.headers_mut().insert("etag", v)); } - resp.headers_mut().insert( + drop(resp.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::handlers::board::activity_html_cache_control( activity_markers_enabled, )), - ); + )); crate::cache::insert_vary_cookie(resp.headers_mut()); Ok((jar, resp).into_response()) } @@ -501,7 +501,7 @@ pub(in crate::server) async fn post_reply( submit_result.thread_id, submit_result.post_id, &submit_result.deletion_token, - submit_result.created_at + crate::handlers::board::self_action_window_secs(), + crate::handlers::board::self_action_expiry(submit_result.created_at)?, crate::handlers::board::should_set_public_secure_cookie(&req_headers, secure_context), ); @@ -616,10 +616,10 @@ async fn render_edit_post_error_page( let mut response = Html(html).into_response(); *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; - response.headers_mut().insert( + drop(response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::cache::CACHE_CONTROL_PRIVATE_NO_STORE), - ); + )); Ok((jar, response).into_response()) } @@ -670,12 +670,7 @@ pub(in crate::server) async fn edit_post_get( } let now = chrono::Utc::now().timestamp(); - if now - > context - .post - .created_at - .saturating_add(crate::handlers::board::self_action_window_secs()) - { + if now > crate::handlers::board::self_action_expiry(context.post.created_at)? { return Err(AppError::Forbidden( "The 60-second edit window for this post has closed.".into(), )); @@ -706,10 +701,10 @@ pub(in crate::server) async fn edit_post_get( None, ); let mut response = Html(html).into_response(); - response.headers_mut().insert( + drop(response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::cache::CACHE_CONTROL_PRIVATE_NO_STORE), - ); + )); Ok((jar, response).into_response()) } @@ -950,12 +945,7 @@ pub(in crate::server) async fn delete_post_get( } let now = chrono::Utc::now().timestamp(); - if now - > context - .post - .created_at - .saturating_add(crate::handlers::board::self_action_window_secs()) - { + if now > crate::handlers::board::self_action_expiry(context.post.created_at)? { return Err(AppError::Forbidden( "The 60-second self-delete window for this post has closed.".into(), )); @@ -987,10 +977,10 @@ pub(in crate::server) async fn delete_post_get( None, ); let mut response = Html(html).into_response(); - response.headers_mut().insert( + drop(response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(crate::cache::CACHE_CONTROL_PRIVATE_NO_STORE), - ); + )); Ok((jar, response).into_response()) } @@ -1216,8 +1206,9 @@ pub(in crate::server) async fn vote_handler( // Voting is persisted participation in the thread, so the same ban // gate as posting applies; CSRF was validated above. crate::handlers::board::ensure_actor_not_banned(&conn, &ip_hash, ban_csrf_token)?; - let (poll_id, thread_id, board_short) = db::get_poll_context(&conn, option_id)? - .ok_or_else(|| AppError::NotFound("Poll option not found.".into()))?; + let (poll_id, resolved_thread_id, resolved_board_short) = + db::get_poll_context(&conn, option_id)? + .ok_or_else(|| AppError::NotFound("Poll option not found.".into()))?; let recorded = db::cast_vote(&conn, poll_id, option_id, &ip_hash)?; if !recorded { @@ -1231,7 +1222,9 @@ pub(in crate::server) async fn vote_handler( option_id = option_id, "Vote cast" ); - Ok(format!("/{board_short}/thread/{thread_id}#poll")) + Ok(format!( + "/{resolved_board_short}/thread/{resolved_thread_id}#poll" + )) } }) .await @@ -1582,7 +1575,8 @@ mod tests { "UPDATE boards SET allow_audio = 1, max_audio_size = ?1 WHERE id = ?2", rusqlite::params![max_audio_size, board_id], ) - .context("failed to enable audio board")?; + .context("failed to enable audio board") + .map(|_completed_value| ())?; Ok(()) } @@ -1763,15 +1757,15 @@ mod tests { .context("failed to receive response")?; assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - let body = String::from_utf8( + let resolved_body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await .context("failed to read response body")? .to_vec(), ) .context("response body was not valid UTF-8")?; - assert!(body.contains("post-error-banner")); - assert!(body.contains("File type not allowed")); + assert!(resolved_body.contains("post-error-banner")); + assert!(resolved_body.contains("File type not allowed")); Ok(()) } @@ -1795,7 +1789,8 @@ mod tests { board_id ], ) - .context("failed to update board toggles")?; + .context("failed to update board toggles") + .map(|_completed_value| ())?; let post = crate::db::NewPost { thread_id: 0, board_id, @@ -1825,9 +1820,13 @@ mod tests { if age_secs > 0 { conn.execute( "UPDATE posts SET created_at = ?1 WHERE id = ?2", - rusqlite::params![chrono::Utc::now().timestamp() - age_secs, post_id], + rusqlite::params![ + chrono::Utc::now().timestamp().saturating_sub(age_secs), + post_id + ], ) - .context("failed to age post")?; + .context("failed to age post") + .map(|_completed_value| ())?; } drop(conn); @@ -1837,7 +1836,9 @@ mod tests { thread_id, post_id, "edit-token", - chrono::Utc::now().timestamp() + crate::handlers::board::self_action_window_secs(), + chrono::Utc::now() + .timestamp() + .saturating_add(crate::handlers::board::self_action_window_secs()), ); let cookie = jar .get("rustchan_owned_posts") @@ -1861,7 +1862,8 @@ mod tests { "UPDATE threads SET locked = ?1, archived = ?2 WHERE id = ?3", rusqlite::params![i64::from(locked), i64::from(archived), thread_id], ) - .context("failed to update thread state")?; + .context("failed to update thread state") + .map(|_completed_value| ())?; Ok(()) } @@ -1874,7 +1876,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, "test", "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -1972,7 +1975,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, "test", "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -2071,7 +2075,8 @@ mod tests { .get() .context("failed to get database connection")?; crate::db::create_board(&conn, "test", "Test", "", false) - .context("failed to create board")?; + .context("failed to create board") + .map(|_completed_value| ())?; } let router = Router::new() @@ -2158,58 +2163,62 @@ mod tests { Ok(()) } + /// Seed the same-client thread whose cooldown should reject a subsequent reply. + fn seed_cooldown_thread(state: &crate::middleware::AppState) -> Result { + let conn = state + .db + .get() + .context("failed to get database connection")?; + let board_id = crate::db::create_board(&conn, "test", "Test", "", false) + .context("failed to create board")?; + conn.execute( + "UPDATE boards SET post_cooldown_secs = 60 WHERE short_name = 'test'", + [], + ) + .context("failed to enable cooldown") + .map(|_completed_value| ())?; + + let ip_hash = + crate::utils::crypto::hash_ip("127.0.0.1", &crate::config::CONFIG.cookie_secret); + let post = crate::db::NewPost { + thread_id: 0, + board_id, + name: "anon".to_owned(), + tripcode: None, + subject: Some("subject".to_owned()), + body: "op body".to_owned(), + body_html: "op body".to_owned(), + ip_hash: Some(ip_hash), + file_path: None, + file_name: None, + file_size: None, + thumb_path: None, + mime_type: None, + media_type: None, + audio_file_path: None, + audio_file_name: None, + audio_file_size: None, + audio_mime_type: None, + deletion_token: "token".to_owned(), + is_op: true, + }; + crate::db::create_thread_with_optional_poll( + &conn, + board_id, + Some("subject"), + &post, + "", + None, + None, + ) + .map(|(created_thread_id, _, _)| created_thread_id) + .context("failed to create thread") + } + #[tokio::test] async fn reply_cooldown_failure_rerenders_thread_inline() -> Result<()> { let state = crate::test_support::app_state(); - let thread_id = { - let conn = state - .db - .get() - .context("failed to get database connection")?; - let board_id = crate::db::create_board(&conn, "test", "Test", "", false) - .context("failed to create board")?; - conn.execute( - "UPDATE boards SET post_cooldown_secs = 60 WHERE short_name = 'test'", - [], - ) - .context("failed to enable cooldown")?; - - let ip_hash = - crate::utils::crypto::hash_ip("127.0.0.1", &crate::config::CONFIG.cookie_secret); - let post = crate::db::NewPost { - thread_id: 0, - board_id, - name: "anon".to_owned(), - tripcode: None, - subject: Some("subject".to_owned()), - body: "op body".to_owned(), - body_html: "op body".to_owned(), - ip_hash: Some(ip_hash), - file_path: None, - file_name: None, - file_size: None, - thumb_path: None, - mime_type: None, - media_type: None, - audio_file_path: None, - audio_file_name: None, - audio_file_size: None, - audio_mime_type: None, - deletion_token: "token".to_owned(), - is_op: true, - }; - let (thread_id, _, _) = crate::db::create_thread_with_optional_poll( - &conn, - board_id, - Some("subject"), - &post, - "", - None, - None, - ) - .context("failed to create thread")?; - thread_id - }; + let thread_id = seed_cooldown_thread(&state)?; let router = Router::new() .route("/{board}/thread/{id}", post(super::post_reply)) diff --git a/src/logging.rs b/src/logging.rs index d65fd010..32e6825b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -76,8 +76,9 @@ pub fn is_tty() -> bool { } /// Returns `true` when stdout is an interactive terminal that can consume ANSI +/// escape sequences. /// -/// escape sequences. On Windows this also probes/enables virtual terminal +/// On Windows this also probes/enables virtual terminal /// processing so raw colour sequences are not printed literally. pub fn ansi_enabled() -> bool { ANSI_ENABLED.load(Ordering::Relaxed) @@ -187,20 +188,22 @@ pub fn read_log_tail(path: &Path, max_bytes: usize) -> Result<(String, bool), St // complete line instead of dropping it as an assumed partial prefix. let read_start = start.saturating_sub(1); file.seek(SeekFrom::Start(read_start)) - .map_err(|error| format!("Seek log: {error}"))?; + .map_err(|error| format!("Seek log: {error}")) + .map(|_completed_value| ())?; let mut bytes = Vec::with_capacity(usize::try_from(length.saturating_sub(read_start)).unwrap_or(max_bytes)); io::copy( &mut file.take(length.saturating_sub(read_start)), &mut bytes, ) - .map_err(|error| format!("Read log: {error}"))?; + .map_err(|error| format!("Read log: {error}")) + .map(|_completed_value| ())?; let truncated = start > 0; let content = if truncated { let prefix = bytes .iter() .position(|byte| *byte == b'\n') - .map_or(1, |index| index + 1); + .map_or(1, |index| index.saturating_add(1)); bytes.get(prefix..).unwrap_or_default() } else { bytes.as_slice() @@ -244,7 +247,7 @@ fn is_dependency_log_target(target: &str) -> bool { /// `rustchan::server::server` → `server` /// `rustchan::db::mod` → `db` /// `rustchan::workers` → `workers` -/// `tower_http::trace` → `trace` +/// `tower_http::trace` → `trace`. fn extract_component(target: &str) -> &str { let mut parts = target.rsplit("::"); let last = parts.next().unwrap_or(target); @@ -477,14 +480,14 @@ fn parse_compound_duration(value: &str) -> Option { .find(|ch: char| !ch.is_ascii_digit()) .filter(|&index| index > 0)?; let (number, unit) = part.split_at(split); - let value = number.parse::().ok()?; + let duration_value = number.parse::().ok()?; let nanos = match unit { - "h" => value.saturating_mul(3_600_000_000_000), - "m" => value.saturating_mul(60_000_000_000), - "s" => value.saturating_mul(1_000_000_000), - "ms" => value.saturating_mul(1_000_000), - "us" | "µs" => value.saturating_mul(1_000), - "ns" => value, + "h" => duration_value.saturating_mul(3_600_000_000_000), + "m" => duration_value.saturating_mul(60_000_000_000), + "s" => duration_value.saturating_mul(1_000_000_000), + "ms" => duration_value.saturating_mul(1_000_000), + "us" | "µs" => duration_value.saturating_mul(1_000), + "ns" => duration_value, _ => return None, }; total_nanos = total_nanos.saturating_add(nanos); @@ -859,7 +862,7 @@ fn rewrite_message(target: &str, file: Option<&str>, fields: &mut LogEventFields "reactor" => { if message.eq_ignore_ascii_case("removing circuit leg") { fields.message = Some("Tor circuit closed".to_owned()); - remove_field(&mut fields.fields, "tunnel_id"); + let _previous_value = remove_field(&mut fields.fields, "tunnel_id"); } else if message.contains("descriptor upload") || message.contains("Unable to upload") || message.contains("publish") @@ -1026,10 +1029,10 @@ fn env_filter() -> EnvFilter { /// Writes one compact line per log event to the terminal. /// /// TTY mode (local dev): -/// `14:22:01.123 [INFO ] [server ] HTTP server listening addr=0.0.0.0:8080` +/// `14:22:01.123 [INFO ] [server ] HTTP server listening addr=0.0.0.0:8080`. /// /// Non-TTY mode (piped / systemd / Docker): -/// `2026-03-18 14:22:01.123 [INFO ] [server ] HTTP server listening addr=0.0.0.0:8080` +/// `2026-03-18 14:22:01.123 [INFO ] [server ] HTTP server listening addr=0.0.0.0:8080`. /// /// Columns are fixed-width so the message text always starts at the same /// horizontal position, making it easy to scan down a busy log stream. @@ -1078,7 +1081,7 @@ where /// /// Format: /// `2026-03-18 14:22:01.123 [INFO ] [server ] HTTP server listening addr=0.0.0.0:8080` -/// `2026-03-18 14:22:01.456 [ERROR] [error ] DB query failed err=no such table (src/db/posts.rs:79)` +/// `2026-03-18 14:22:01.456 [ERROR] [error ] DB query failed err=no such table (src/db/posts.rs:79)`. /// /// Differences from the terminal format: /// • Always UTC with the full date — the file is an archive, not a live view. @@ -1370,7 +1373,7 @@ mod tests { .iter() .filter_map(|(name, value)| { let clean = normalize_field_value(name, value); - (!super::should_hide_field(name, &clean)).then(|| ((*name).to_string(), clean)) + (!super::should_hide_field(name, &clean)).then(|| ((*name).to_owned(), clean)) }) .collect(), }; @@ -1582,17 +1585,17 @@ mod tests { vec![("retry_in".to_owned(), "30s".to_owned())] ); - let fields = rewrite_for_test( + let disabled_guard_fields = rewrite_for_test( "tor_guardmgr::guard", "Disabling guard: 72.5% of circuits died under mysterious circumstances, exceeding threshold of 70.0%", &[("guard", "GuardId(abc)")], ); assert_eq!( - fields.message.as_deref(), + disabled_guard_fields.message.as_deref(), Some("Tor disabled an unstable guard") ); assert_eq!( - fields.fields, + disabled_guard_fields.fields, vec![("failure_rate".to_owned(), "72.5%".to_owned())] ); } @@ -1619,16 +1622,19 @@ mod tests { )] ); - let fields = rewrite_for_test( + let bootstrap_failure_fields = rewrite_for_test( "tor_dirmgr::bootstrap", "We failed 3 times to bootstrap a directory. We're going to give up.", &[], ); assert_eq!( - fields.message.as_deref(), + bootstrap_failure_fields.message.as_deref(), Some("Tor directory bootstrap failed") ); - assert_eq!(fields.fields, vec![("attempts".to_owned(), "3".to_owned())]); + assert_eq!( + bootstrap_failure_fields.fields, + vec![("attempts".to_owned(), "3".to_owned())] + ); } #[test] @@ -1643,14 +1649,17 @@ mod tests { Some("Tor onion-service circuits are failing; waiting before retrying") ); - let fields = rewrite_for_test( + let circuit_fields = rewrite_for_test( "tor_proto::client::reactor", "removing circuit leg", &[("tunnel_id", "Circ 8.98"), ("reason", "closed")], ); - assert_eq!(fields.message.as_deref(), Some("Tor circuit closed")); assert_eq!( - fields.fields, + circuit_fields.message.as_deref(), + Some("Tor circuit closed") + ); + assert_eq!( + circuit_fields.fields, vec![("reason".to_owned(), "closed".to_owned())] ); } diff --git a/src/main.rs b/src/main.rs index 08d86ad3..d3b901d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ fn main() -> anyhow::Result<()> { "{}", serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "target": updates::platform_target(), - "schema": db::baseline_schema_version(), "minimum_schema": "1.5.0", "updater_protocol": 1 + "schema": db::baseline_schema_version(), "minimum_schema": "1.5.0", "updater_protocol": 1_u32 }) )?; return Ok(()); diff --git a/src/media/audio.rs b/src/media/audio.rs index af8079d6..c8933c5d 100644 --- a/src/media/audio.rs +++ b/src/media/audio.rs @@ -53,7 +53,9 @@ impl PacketDecoder { if params.codec != CODEC_ID_OPUS { if let Some(frames) = params.max_frames_per_packet { ensure!( - u128::from(frames) * u128::try_from(channels)? + u128::from(frames) + .checked_mul(u128::try_from(channels)?) + .context("audio sample budget overflow")? <= u128::try_from(MAX_PACKET_SAMPLES)?, "audio packet exceeds sample budget" ); @@ -94,7 +96,7 @@ impl PacketDecoder { pcm.copy_to_slice_interleaved(samples.as_mut_slice()); } Self::Opus(decoder) => { - decoder.decode(&packet.data, samples, || budget.check())?; + let _decoded_frames = decoder.decode(&packet.data, samples, || budget.check())?; } } ensure!( @@ -153,7 +155,7 @@ fn visit_discrete_opus( if !reader.read_packet_into(&mut packet)? { break; } - decoder.decode(&packet.data, &mut samples, || budget.check())?; + let _decoded_frames = decoder.decode(&packet.data, &mut samples, || budget.check())?; ensure!( samples.iter().all(|sample| sample.is_finite()), "non-finite Opus PCM" @@ -215,17 +217,25 @@ impl OpusPresentation { channels: usize, ) -> Result<&'a [f32]> { let to_frames = |duration: symphonia::core::units::Duration| -> Result { - let numerator = - u128::from(duration.get()) * u128::from(self.time_base.numer.get()) * 48_000; + let numerator = u128::from(duration.get()) + .checked_mul(u128::from(self.time_base.numer.get())) + .and_then(|numerator| numerator.checked_mul(48_000)) + .context("Opus timestamp conversion overflow")?; let denominator = u128::from(self.time_base.denom.get()); Ok(usize::try_from( - (numerator + denominator / 2) / denominator, + numerator + .checked_add(denominator / 2) + .and_then(|rounded| rounded.checked_div(denominator)) + .context("invalid Opus timestamp denominator or overflow")?, )?) }; // Ogg supplies exact trimming; Matroska can round delay to milliseconds. - let start = to_frames(packet.trim_start)? - .max(self.delay) - .min(samples.len() / channels); + let start = to_frames(packet.trim_start)?.max(self.delay).min( + samples + .len() + .checked_div(channels) + .context("Opus has no channels")?, + ); self.delay = self.delay.saturating_sub(start); let end = to_frames(packet.trim_end)?; let first = start.checked_mul(channels).context("Opus trim overflow")?; @@ -452,8 +462,8 @@ fn visit_samples( // Mean absolute channel amplitude avoids cancelling out-of-phase stereo. for frame in presentation.chunks_exact(channels) { let amplitude = frame_amplitude(frame, gain)?; - let emitted = if let Some(presentation) = &mut movie_presentation { - presentation.frame(amplitude, &mut visit, || budget.check())? + let emitted = if let Some(movie_timeline) = &mut movie_presentation { + movie_timeline.frame(amplitude, &mut visit, || budget.check())? } else { visit(amplitude)?; 1 @@ -498,8 +508,13 @@ pub fn render_waveform( let mut peaks = vec![0.0_f32; usize::try_from(width)?]; let mut position = 0_u64; let actual = visit_samples(input, &budget, |amplitude| { - let bucket = u128::from(position) * u128::from(width) / u128::from(total); - let index = usize::try_from(bucket.min(u128::from(width - 1)))?; + let bucket = u128::from(position) + .checked_mul(u128::from(width)) + .and_then(|scaled| scaled.checked_div(u128::from(total))) + .context("invalid waveform sample range")?; + let index = usize::try_from(bucket.min(u128::from( + width.checked_sub(1).context("waveform width is empty")?, + )))?; let peak = peaks .get_mut(index) .context("waveform bucket out of range")?; @@ -532,7 +547,11 @@ fn render_peaks(peaks: &[f32], width: u32, height: u32) -> Result { for (column, peak) in peaks.iter().enumerate() { let radius = (peak.clamp(0.0, 1.0) * radius_scale).round() as u32; let x = u32::try_from(column)?; - for y in middle.saturating_sub(radius)..=middle.saturating_add(radius).min(height - 1) { + for y in middle.saturating_sub(radius) + ..=middle + .saturating_add(radius) + .min(height.checked_sub(1).context("waveform height is empty")?) + { image.put_pixel(x, y, Rgba([0x88, 0x88, 0x88, 255])); } } @@ -652,14 +671,29 @@ mod tests { .context("PCM length")?; let mut file = std::fs::File::create(path)?; file.write_all(b"RIFF")?; - file.write_all(&(data_len + 36).to_le_bytes())?; + file.write_all( + &data_len + .checked_add(36) + .context("PCM RIFF length overflow")? + .to_le_bytes(), + )?; file.write_all(b"WAVEfmt ")?; file.write_all(&16_u32.to_le_bytes())?; file.write_all(&1_u16.to_le_bytes())?; file.write_all(&channels.to_le_bytes())?; file.write_all(&8000_u32.to_le_bytes())?; - file.write_all(&(8000 * u32::from(channels) * 2).to_le_bytes())?; - file.write_all(&(channels * 2).to_le_bytes())?; + file.write_all( + &u32::from(channels) + .checked_mul(16_000) + .context("PCM byte rate overflow")? + .to_le_bytes(), + )?; + file.write_all( + &channels + .checked_mul(2) + .context("PCM block size overflow")? + .to_le_bytes(), + )?; file.write_all(&16_u16.to_le_bytes())?; file.write_all(b"data")?; file.write_all(&data_len.to_le_bytes())?; @@ -668,7 +702,12 @@ mod tests { for channel in 0..channels { let sample = if silence { 0_i16 - } else if (frame + u32::from(channel)) % 2 == 0 { + } else if frame + .checked_add(u32::from(channel)) + .context("PCM frame position overflow")? + % 2 + == 0 + { 16_000_i16 } else { -16_000_i16 @@ -890,11 +929,11 @@ mod tests { .context("truncated SBR configuration was accepted")?; ensure!(!is_unsupported(&error), "truncated SBR selected fallback"); for bytes in [&[0x28][..], &[0x00, 0x90], &[0x17, 0x10]] { - let error = aac_needs_compatibility(bytes) + let invalid_config_error = aac_needs_compatibility(bytes) .err() .context("invalid AAC config accepted")?; ensure!( - !is_unsupported(&error), + !is_unsupported(&invalid_config_error), "invalid AAC config selected fallback" ); } @@ -951,13 +990,13 @@ mod tests { .context("AAC fixture truncation outside packet")? .to_vec() .into_boxed_slice(); - let error = PacketDecoder::new(¶ms, channels)? + let truncated_packet_error = PacketDecoder::new(¶ms, channels)? .decode(&truncated, channels, &mut Vec::new(), &budget) .err() .context("truncated AAC raw-data block was accepted")?; ensure!( - !is_unsupported(&error), - "truncated SBR payload selected compatibility: {error:#}" + !is_unsupported(&truncated_packet_error), + "truncated SBR payload selected compatibility: {truncated_packet_error:#}" ); } Ok(()) diff --git a/src/media/audio_mp4.rs b/src/media/audio_mp4.rs index 46b9a767..4f231114 100644 --- a/src/media/audio_mp4.rs +++ b/src/media/audio_mp4.rs @@ -51,7 +51,10 @@ impl Presentation { emitted = emitted .checked_add(*count) .context("MP4 presentation length overflow")?; - self.next += 1; + self.next = self + .next + .checked_add(1) + .context("MP4 edit cursor overflow")?; } Ok(emitted) } @@ -77,7 +80,10 @@ impl Presentation { .context("MP4 frame position overflow")? >= *end { - self.next += 1; + self.next = self + .next + .checked_add(1) + .context("MP4 edit cursor overflow")?; } } self.position = self @@ -137,7 +143,7 @@ impl Result<()>> Reader { .is_some_and(|end| end <= atom.end), "truncated MP4 timing field" ); - self.file.seek(SeekFrom::Start(start))?; + let _position = self.file.seek(SeekFrom::Start(start))?; let mut bytes = [0; N]; self.file.read_exact(&mut bytes)?; Ok(bytes) @@ -150,10 +156,16 @@ impl Result<()>> Reader { while cursor < end { (self.check)()?; ensure!( - self.boxes < MAX_BOXES && end - cursor >= 8, + self.boxes < MAX_BOXES + && end + .checked_sub(cursor) + .is_some_and(|remaining| remaining >= 8), "MP4 timing box budget or extent invalid" ); - self.boxes += 1; + self.boxes = self + .boxes + .checked_add(1) + .context("MP4 box count overflow")?; let outer = Atom { kind: [0; 4], body: cursor, @@ -162,7 +174,11 @@ impl Result<()>> Reader { let size = u32::from_be_bytes(self.field::<4>(outer, 0)?); let kind = self.field::<4>(outer, 4)?; let (size, header) = match size { - 0 => (end - cursor, 8), + 0 => ( + end.checked_sub(cursor) + .context("invalid MP4 box boundary")?, + 8, + ), 1 => (u64::from_be_bytes(self.field::<8>(outer, 8)?), 16), size => (u64::from(size), 8), }; @@ -175,7 +191,9 @@ impl Result<()>> Reader { ); atoms.push(Atom { kind, - body: cursor + header, + body: cursor + .checked_add(header) + .context("MP4 box header overflow")?, end: next, }); cursor = next; @@ -183,6 +201,44 @@ impl Result<()>> Reader { Ok(atoms) } + /// Decode one bounded edit, validating media time before rate compatibility. + fn edit_entry(&mut self, elst: Atom, offset: u64, version: u8) -> Result<(u64, i64, bool)> { + let (duration, start, rate_offset) = if version == 0 { + ( + u64::from(u32::from_be_bytes(self.field::<4>(elst, offset)?)), + i64::from(i32::from_be_bytes( + self.field::<4>( + elst, + offset + .checked_add(4) + .context("MP4 edit field offset overflow")?, + )?, + )), + offset + .checked_add(8) + .context("MP4 edit field offset overflow")?, + ) + } else { + ( + u64::from_be_bytes(self.field::<8>(elst, offset)?), + i64::from_be_bytes( + self.field::<8>( + elst, + offset + .checked_add(8) + .context("MP4 edit field offset overflow")?, + )?, + ), + offset + .checked_add(16) + .context("MP4 edit field offset overflow")?, + ) + }; + ensure!(start >= -1, "invalid MP4 edit media time"); + let non_unit_rate = self.field::<4>(elst, rate_offset)? != [0, 1, 0, 0]; + Ok((duration, start, non_unit_rate)) + } + /// Validate full-box version and select its 32/64-bit timestamp fields. fn timestamp_offset(&mut self, atom: Atom) -> Result { match self.field::<1>(atom, 0)? { @@ -270,7 +326,10 @@ pub(in crate::media) fn declared_video_codec( _ => return Ok(None), }; ensure!( - entry.end - entry.body >= 78, + entry + .end + .checked_sub(entry.body) + .is_some_and(|size| size >= 78), "truncated MP4 visual sample entry" ); Ok(Some(codec)) @@ -280,10 +339,22 @@ pub(in crate::media) fn declared_video_codec( fn frames(units: u64, scale: u32, rate: u32) -> Result { ensure!(scale > 0 && rate > 0, "invalid MP4 timing scale"); Ok(u64::try_from( - (u128::from(units) * u128::from(rate) + u128::from(scale) / 2) / u128::from(scale), + u128::from(units) + .checked_mul(u128::from(rate)) + .and_then(|numerator| numerator.checked_add(u128::from(scale) / 2)) + .and_then(|rounded| rounded.checked_div(u128::from(scale))) + .context("MP4 sample frame conversion overflow")?, )?) } +/// Compute the checked byte offset of an edit-table entry after its header. +fn edit_offset(index: u64, size: u64) -> Result { + index + .checked_mul(size) + .and_then(|bytes| bytes.checked_add(8)) + .context("MP4 edit table offset overflow") +} + /// Valid but unimplemented temporal operations retain an explicit compatibility path. fn unsupported() -> anyhow::Error { symphonia::core::errors::Error::Unsupported( @@ -343,7 +414,7 @@ pub(super) fn read( ensure!(count <= MAX_EDITS, "MP4 edit budget exceeded"); let size = if version == 0 { 12 } else { 20 }; ensure!( - elst.end - elst.body == 8 + u64::try_from(count)? * size, + elst.end.checked_sub(elst.body) == Some(edit_offset(u64::try_from(count)?, size)?), "invalid MP4 edit table size" ); let mut edits = Vec::with_capacity(count); @@ -351,36 +422,25 @@ pub(super) fn read( let mut previous_end = 0_u64; let mut compatibility = false; for index in 0..count { - let offset = 8 + u64::try_from(index)? * size; - let (duration, start, rate_offset) = if version == 0 { - ( - u64::from(u32::from_be_bytes(reader.field::<4>(elst, offset)?)), - i64::from(i32::from_be_bytes(reader.field::<4>(elst, offset + 4)?)), - offset + 8, - ) - } else { - ( - u64::from_be_bytes(reader.field::<8>(elst, offset)?), - i64::from_be_bytes(reader.field::<8>(elst, offset + 8)?), - offset + 16, - ) - }; - ensure!(start >= -1, "invalid MP4 edit media time"); - compatibility |= reader.field::<4>(elst, rate_offset)? != [0, 1, 0, 0]; + let offset = edit_offset(u64::try_from(index)?, size)?; + let (duration, start, non_unit_rate) = reader.edit_entry(elst, offset, version)?; + compatibility |= non_unit_rate; let old_end = frames(movie_end, movie_scale, rate)?; movie_end = movie_end .checked_add(duration) .context("MP4 movie duration overflow")?; - let duration = frames(movie_end, movie_scale, rate)? - old_end; - if duration == 0 { + let duration_frames = frames(movie_end, movie_scale, rate)? + .checked_sub(old_end) + .context("MP4 edit frame range is reversed")?; + if duration_frames == 0 { continue; } if start == -1 { - edits.push(Edit::Silence(duration)); + edits.push(Edit::Silence(duration_frames)); } else { let start = frames(u64::try_from(start)?, media_scale, rate)?; let end = start - .checked_add(duration) + .checked_add(duration_frames) .context("MP4 media duration overflow")?; compatibility |= start < previous_end; previous_end = end; @@ -499,7 +559,7 @@ mod tests { next: 0, position: 0, }; - missing.frame(0.5, &mut |_| Ok(()), || Ok(()))?; + let _decoded_frames = missing.frame(0.5, &mut |_| Ok(()), || Ok(()))?; ensure!( missing.finish(&mut |_| Ok(()), || Ok(())).is_err(), "missing media was invented" diff --git a/src/media/audio_opus.rs b/src/media/audio_opus.rs index 57065893..7579f5bb 100644 --- a/src/media/audio_opus.rs +++ b/src/media/audio_opus.rs @@ -39,7 +39,7 @@ impl Decoder { /// Validate metadata without allocating codec reconstruction state during probing. pub(super) fn validate(header: &[u8], channels: usize) -> Result<()> { - Self::layout(header, channels)?; + let _validated_layout = Self::layout(header, channels)?; Ok(()) } @@ -48,7 +48,7 @@ impl Decoder { ensure!((1..=255).contains(&channels), "invalid Opus channel count"); ensure!(header.get(..8) == Some(b"OpusHead"), "invalid Opus header"); ensure!( - header.get(8).is_some_and(|version| version >> 4 == 0), + header.get(8).is_some_and(|version| version >> 4_i32 == 0), "unsupported Opus header version" ); ensure!( @@ -63,23 +63,32 @@ impl Decoder { ); ( 1, - channels - 1, + channels + .checked_sub(1) + .context("invalid Opus single-stream channels")?, if channels == 1 { vec![0] } else { vec![0, 1] }, ) } else { let streams = usize::from(*header.get(19).context("truncated Opus stream count")?); let coupled = usize::from(*header.get(20).context("truncated Opus coupled count")?); let mapping = header - .get(21..21 + channels) + .get( + 21..21_usize + .checked_add(channels) + .context("Opus channel map size overflow")?, + ) .context("truncated Opus channel map")?; + let coded_channels = streams + .checked_add(coupled) + .context("Opus coded channel count overflow")?; ensure!( - streams > 0 && coupled <= streams && streams + coupled <= 255, + streams > 0 && coupled <= streams && coded_channels <= 255, "invalid Opus stream mapping" ); ensure!( mapping .iter() - .all(|slot| *slot == 255 || usize::from(*slot) < streams + coupled), + .all(|slot| *slot == 255 || usize::from(*slot) < coded_channels), "invalid Opus channel mapping" ); if !matches!(family, 1 | 2 | 255) { @@ -119,8 +128,13 @@ impl Decoder { for (index, decoder) in self.streams.iter_mut().enumerate() { check()?; let stream_channels = if index < self.coupled { 2 } else { 1 }; - self.pcm.resize(MAX_PACKET_SAMPLES * stream_channels, 0.0); - let encoded = if index + 1 == count { + self.pcm.resize( + MAX_PACKET_SAMPLES + .checked_mul(stream_channels) + .context("Opus PCM buffer overflow")?, + 0.0, + ); + let encoded = if index.checked_add(1) == Some(count) { remaining } else { let consumed = normalize_self_delimited(remaining, &mut self.packet)?; @@ -139,13 +153,23 @@ impl Decoder { } frames = Some(decoded); let base = if index < self.coupled { - index * 2 + index + .checked_mul(2) + .context("Opus coupled channel offset overflow")? } else { - index + self.coupled + index + .checked_add(self.coupled) + .context("Opus channel offset overflow")? }; + let end = base + .checked_add(stream_channels) + .context("Opus stream channel range overflow")?; for (channel, mapped) in self.mapping.iter().copied().enumerate() { let mapped = usize::from(mapped); - if mapped >= base && mapped < base + stream_channels { + if mapped >= base && mapped < end { + let stream_channel = mapped + .checked_sub(base) + .context("invalid Opus channel offset")?; for (target, source) in output .chunks_exact_mut(channels) .take(decoded) @@ -154,14 +178,18 @@ impl Decoder { *target .get_mut(channel) .context("Opus output channel is missing")? = *source - .get(mapped - base) + .get(stream_channel) .context("Opus coded channel is missing")?; } } } } let frames = frames.context("Opus packet has no streams")?; - output.truncate(frames * channels); + output.truncate( + frames + .checked_mul(channels) + .context("Opus output buffer overflow")?, + ); Ok(frames) } } @@ -207,7 +235,7 @@ fn normalize_self_delimited(data: &[u8], output: &mut Vec) -> Result } }; let samples_per_frame = if toc & 128 != 0 { - 120 << ((toc >> 3) & 3) + 120 << ((toc >> 3_i32) & 3) } else if toc & 0x60 == 0x60 { if toc & 8 != 0 { 960 @@ -216,11 +244,13 @@ fn normalize_self_delimited(data: &[u8], output: &mut Vec) -> Result } } else { *[480, 960, 1920, 2880] - .get(usize::from((toc >> 3) & 3)) + .get(usize::from((toc >> 3_i32) & 3)) .context("invalid Opus TOC")? }; ensure!( - count * samples_per_frame <= MAX_PACKET_SAMPLES, + count + .checked_mul(samples_per_frame) + .is_some_and(|samples| samples <= MAX_PACKET_SAMPLES), "Opus packet exceeds duration limit" ); let extra_start = cursor; @@ -251,7 +281,9 @@ fn normalize_self_delimited(data: &[u8], output: &mut Vec) -> Result /// Read one bounded length byte. fn read_byte(data: &[u8], cursor: &mut usize) -> Result { let value = *data.get(*cursor).context("truncated Opus packet header")?; - *cursor += 1; + *cursor = cursor + .checked_add(1) + .context("Opus packet cursor overflow")?; Ok(value) } @@ -261,7 +293,10 @@ fn read_size(data: &[u8], cursor: &mut usize) -> Result { Ok(if first < 252 { usize::from(first) } else { - usize::from(first) + 4 * usize::from(read_byte(data, cursor)?) + usize::from(read_byte(data, cursor)?) + .checked_mul(4) + .and_then(|high| high.checked_add(usize::from(first))) + .context("Opus frame size overflow")? }) } @@ -333,7 +368,7 @@ mod tests { let mut actual = Vec::new(); for packet in reader.packets() { let packet = packet?; - decoder.decode(&packet.data, &mut output, || Ok(()))?; + let _decoded_frames = decoder.decode(&packet.data, &mut output, || Ok(()))?; actual.extend( trim.keep(&packet, &output) .iter() diff --git a/src/media/convert.rs b/src/media/convert.rs index fe0762aa..eba01f18 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -208,7 +208,7 @@ fn copy_as_is_with_mime( final_mime: &'static str, ) -> Result { let output = output_dir.join(format!("{file_stem}.{ext}")); - std::fs::copy(input, &output) + let _bytes_copied = std::fs::copy(input, &output) .with_context(|| format!("failed to copy upload to {}", output.display()))?; let final_size = file_size(&output)?; Ok(ConversionResult { diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs index b8eaa16d..373958a6 100644 --- a/src/media/ffmpeg.rs +++ b/src/media/ffmpeg.rs @@ -140,7 +140,7 @@ fn capabilities_from_lists( #[must_use] pub fn detect_ffmpeg() -> bool { let mut command = ffmpeg_command(); - command.arg("-version"); + let _configured_command = command.arg("-version"); run_command_with_timeout(&mut command, &crate::config::CONFIG.ffmpeg_path, "ffmpeg") .is_ok_and(|output| output.status.success()) } @@ -197,7 +197,7 @@ pub(crate) fn probe_uncovered_audio(path: &Path) -> Result Result { let mut command = ffmpeg_command(); - command.args([ + let _configured_command = command.args([ "-v", "error", "-i", path, "-map", selector, frames, "0", "-f", "null", "-", ]); let output = run_command_with_timeout( @@ -243,9 +243,11 @@ pub fn ffmpeg_thumbnail(input: &Path, output: &Path, max_dim: u32) -> Result<()> ]) .context("video frame extraction failed")?; super::thumbnail::image_crate_thumbnail(frame.path(), "image/png", thumbnail.path(), max_dim)?; - thumbnail - .persist(output) - .context("persist Rust-encoded video thumbnail")?; + drop( + thumbnail + .persist(output) + .context("persist Rust-encoded video thumbnail")?, + ); Ok(()) } @@ -440,7 +442,7 @@ fn run_command_with_timeout( fn output_stdout_with_timeout(program: &str, args: &[&str]) -> Option { let timeout = Duration::from_secs(10); let mut command = Command::new(program); - command.args(args); + let _configured_command = command.args(args); crate::media::process::run_std_command_with_timeout( &mut command, timeout, @@ -561,7 +563,7 @@ mod tests { let arg_index = paired_arg_index(args, flag, value) .with_context(|| format!("missing ffmpeg arg pair {flag} {value}: {args:?}"))?; assert!( - arg_index > input_index + 1, + Some(arg_index) > input_index.checked_add(1), "{flag} {value} must be an output option after the input argument: {args:?}", ); Ok(arg_index) @@ -646,10 +648,11 @@ mod tests { // compatibility pixel formats. VP9 transcode is the path that combines // a forced yuv420p profile-0 output with potentially inherited source // color metadata, so it owns the explicit BT.709 normalization contract. - output_arg_index(&args, "-pix_fmt", VP9_COMPAT_PIXEL_FORMAT)?; - output_arg_index(&args, "-colorspace", VP9_COMPAT_COLOR_SPACE)?; - output_arg_index(&args, "-color_primaries", VP9_COMPAT_COLOR_SPACE)?; - output_arg_index(&args, "-color_trc", VP9_COMPAT_COLOR_SPACE)?; + let _pixel_format_index = output_arg_index(&args, "-pix_fmt", VP9_COMPAT_PIXEL_FORMAT)?; + let _color_space_index = output_arg_index(&args, "-colorspace", VP9_COMPAT_COLOR_SPACE)?; + let _color_primaries_index = + output_arg_index(&args, "-color_primaries", VP9_COMPAT_COLOR_SPACE)?; + let _color_transfer_index = output_arg_index(&args, "-color_trc", VP9_COMPAT_COLOR_SPACE)?; Ok(()) } } diff --git a/src/media/heif.rs b/src/media/heif.rs index f6a7449a..18ca6348 100644 --- a/src/media/heif.rs +++ b/src/media/heif.rs @@ -21,7 +21,7 @@ pub(crate) fn decode(input: &Path) -> Result { "HEIC input exceeds memory safety limit" ); let mut bytes = Vec::new(); - file.take(limit + 1).read_to_end(&mut bytes)?; + let _bytes_read = file.take(limit + 1).read_to_end(&mut bytes)?; ensure!( u64::try_from(bytes.len())? <= limit, "HEIC input grew beyond safety limit" @@ -73,7 +73,11 @@ fn preflight_item(context: &Context<'_>, item: u32) -> Result { for nal in &array.nals { let (width, height) = sps_dimensions(nal)?; super::images::validate_dimensions(width, height)?; - largest = largest.max(u64::from(width) * u64::from(height)); + largest = largest.max( + u64::from(width) + .checked_mul(u64::from(height)) + .context("HEIC tile area overflow")?, + ); } } } @@ -124,7 +128,12 @@ fn sps_dimensions(nal: &[u8]) -> Result<(u32, u32)> { sublayers.push((bits.read(1)? != 0, bits.read(1)? != 0)); } if layers > 0 { - bits.skip((8 - layers) * 2)?; + bits.skip( + 8_usize + .checked_sub(layers) + .and_then(|remaining| remaining.checked_mul(2)) + .context("invalid HEIC sublayer count")?, + )?; } for (profile, level) in sublayers { if profile { @@ -161,8 +170,18 @@ impl Bits<'_> { .data .get(self.position / 8) .context("truncated HEIC sequence bits")?; - value = (value << 1) | u32::from((byte >> (7 - self.position % 8)) & 1); - self.position += 1; + value = (value << 1_i32) + | u32::from( + (byte + >> (7_usize + .checked_sub(self.position % 8) + .context("invalid HEIC bit offset")?)) + & 1, + ); + self.position = self + .position + .checked_add(1) + .context("HEIC bit offset overflow")?; } Ok(value) } @@ -182,12 +201,18 @@ impl Bits<'_> { /// Read a bounded unsigned Exp-Golomb metadata integer. fn unsigned_exp_golomb(&mut self) -> Result { - let mut zeroes = 0; + let mut zeroes = 0_usize; while self.read(1)? == 0 { - zeroes += 1; + zeroes = zeroes + .checked_add(1) + .context("HEIC sequence prefix overflow")?; ensure!(zeroes < 32, "HEIC sequence integer overflows"); } - Ok((1_u32 << zeroes) - 1 + self.read(zeroes)?) + let suffix = self.read(zeroes)?; + (1_u32 << zeroes) + .checked_sub(1) + .and_then(|prefix| prefix.checked_add(suffix)) + .context("HEIC sequence integer overflows") } } @@ -217,7 +242,7 @@ mod tests { "HEIC conversion changed pixels for {name}" ); let thumbnail = outputs.path().join(format!("{name}-thumb.webp")); - super::super::thumbnail::generate_thumbnail( + let _thumbnail_path = super::super::thumbnail::generate_thumbnail( &root.join(name), "image/heic", &thumbnail, @@ -248,7 +273,7 @@ mod tests { ); let dir = tempfile::tempdir()?; let heif = dir.path().join("image.heif"); - std::fs::copy(root.join("photo.heic"), &heif)?; + let _bytes_copied = std::fs::copy(root.join("photo.heic"), &heif)?; ensure!( decode(&heif)?.width() == 2048, "HEIF extension lost support" diff --git a/src/media/images.rs b/src/media/images.rs index 70c3ef43..e1639c99 100644 --- a/src/media/images.rs +++ b/src/media/images.rs @@ -25,7 +25,10 @@ const MAX_U24: u32 = 0x00ff_ffff; pub(crate) fn validate_dimensions(width: u32, height: u32) -> Result<()> { ensure!(width > 0 && height > 0, "image has an empty canvas"); ensure!( - u64::from(width) * u64::from(height) <= super::MAX_UNTRUSTED_IMAGE_PIXELS, + u64::from(width) + .checked_mul(u64::from(height)) + .context("image area overflow")? + <= super::MAX_UNTRUSTED_IMAGE_PIXELS, "image dimensions {width}x{height} exceed the safety limit" ); Ok(()) @@ -121,7 +124,7 @@ pub(crate) fn image_to_webp(input: &Path, output: &Path, bounds: Option<(u32, u3 } _ => write_still(input, bounds, temporary.as_file_mut())?, } - temporary.persist(output).context("publish WebP image")?; + drop(temporary.persist(output).context("publish WebP image")?); Ok(()) } @@ -181,10 +184,10 @@ fn gif_loops(input: &Path, budget: &AnimationBudget) -> Result { )); let mut decoder = options.read_info(BufReader::new(File::open(input)?))?; validate_dimensions(u32::from(decoder.width()), u32::from(decoder.height()))?; - let mut count = 0; + let mut count = 0_usize; while decoder.next_frame_info()?.is_some() { budget.check(count)?; - count += 1; + count = count.checked_add(1).context("GIF frame count overflow")?; } ensure!(count > 0, "GIF contains no frames"); match decoder.repeat() { @@ -231,7 +234,7 @@ fn encode_animation( ensure!(dimensions.is_some(), "animation contains no frames"); let end = output.stream_position()?; let riff_size = u32::try_from(end.checked_sub(8).context("invalid RIFF size")?)?; - output.seek(SeekFrom::Start(4))?; + let _position = output.seek(SeekFrom::Start(4))?; output.write_all(&riff_size.to_le_bytes())?; Ok(()) } @@ -246,8 +249,16 @@ fn write_animation_header( output.write_all(b"RIFF\0\0\0\0WEBPVP8X")?; output.write_all(&10_u32.to_le_bytes())?; output.write_all(&[0x12, 0, 0, 0])?; - write_u24(output, width - 1)?; - write_u24(output, height - 1)?; + write_u24( + output, + width.checked_sub(1).context("WebP canvas width is empty")?, + )?; + write_u24( + output, + height + .checked_sub(1) + .context("WebP canvas height is empty")?, + )?; output.write_all(b"ANIM")?; output.write_all(&6_u32.to_le_bytes())?; output.write_all(&[0; 4])?; @@ -288,12 +299,24 @@ fn write_animation_frame(output: &mut File, pixels: &RgbaImage, duration: u32) - .to_le_bytes(), )?; output.write_all(&[0; 6])?; - write_u24(output, pixels.width() - 1)?; - write_u24(output, pixels.height() - 1)?; + write_u24( + output, + pixels + .width() + .checked_sub(1) + .context("WebP frame width is empty")?, + )?; + write_u24( + output, + pixels + .height() + .checked_sub(1) + .context("WebP frame height is empty")?, + )?; write_u24(output, duration)?; output.write_all(&[2])?; - encoded.seek(SeekFrom::Start(12))?; - std::io::copy(&mut encoded.take(u64::from(payload_size)), output)?; + let _position = encoded.seek(SeekFrom::Start(12))?; + let _bytes_copied = std::io::copy(&mut encoded.take(u64::from(payload_size)), output)?; Ok(()) } diff --git a/src/media/images/tests.rs b/src/media/images/tests.rs index aa6fff4e..c7633548 100644 --- a/src/media/images/tests.rs +++ b/src/media/images/tests.rs @@ -28,7 +28,7 @@ fn gif_fixture(path: &Path, repeat: gif::Repeat) -> Result<()> { ..Default::default() })?; } - encoder.into_inner()?; + let _writer = encoder.into_inner()?; Ok(()) } @@ -56,10 +56,13 @@ fn gif_webp_preserves_partial_updates_disposal_delays_and_loops() -> Result<()> ensure!(loops == expected_loops, "loop count changed"); let actual = decoder.into_frames().collect_frames()?; ensure!(actual.len() == 4, "frame count changed"); - for (actual, expected) in actual.iter().zip(&expected) { - ensure!(actual.delay() == expected.delay(), "frame delay changed"); + for (actual_frame, expected_frame) in actual.iter().zip(&expected) { ensure!( - actual.buffer() == expected.buffer(), + actual_frame.delay() == expected_frame.delay(), + "frame delay changed" + ); + ensure!( + actual_frame.buffer() == expected_frame.buffer(), "composited pixels changed" ); } @@ -213,7 +216,7 @@ fn static_formats_encode_to_decodable_metadata_free_webp() -> Result<()> { let expected = decode_still(&input)?.into_rgba8(); let actual = decode_still(&output)?.into_rgba8(); let thumbnail = dir.path().join(format!("thumb-{ext}.webp")); - super::super::thumbnail::generate_thumbnail( + let _thumbnail_path = super::super::thumbnail::generate_thumbnail( &input, &format!("image/{ext}"), &thumbnail, @@ -268,7 +271,8 @@ fn jpeg_exif_rotation_survives_conversion_and_thumbnailing_without_tools() -> Re "JPEG EXIF rotation was lost" ); let thumbnail = dir.path().join("thumb.webp"); - super::super::thumbnail::generate_thumbnail(&input, "image/jpeg", &thumbnail, 6, false)?; + let _thumbnail_path = + super::super::thumbnail::generate_thumbnail(&input, "image/jpeg", &thumbnail, 6, false)?; ensure!( image::open(thumbnail)?.into_rgba8().dimensions() == (3, 6), "oriented thumbnail dimensions changed" @@ -307,7 +311,8 @@ fn large_valid_image_and_truncated_formats_preserve_atomic_output() -> Result<() let pixels = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4096, 2048, Rgba([42, 73, 101, 128]))); pixels.save_with_format(&input, ImageFormat::Png)?; - super::super::thumbnail::generate_thumbnail(&input, "image/png", &output, 256, false)?; + let _thumbnail_path = + super::super::thumbnail::generate_thumbnail(&input, "image/png", &output, 256, false)?; let image = decode_still(&output)?.into_rgba8(); ensure!( image.dimensions() == (256, 128), @@ -324,8 +329,8 @@ fn large_valid_image_and_truncated_formats_preserve_atomic_output() -> Result<() ImageFormat::Tiff, ImageFormat::WebP, ] { - let image = DynamicImage::ImageRgb8(image::RgbImage::new(32, 16)); - image.save_with_format(&input, format)?; + let fixture_image = DynamicImage::ImageRgb8(image::RgbImage::new(32, 16)); + fixture_image.save_with_format(&input, format)?; let bytes = std::fs::read(&input)?; std::fs::write(&input, bytes.get(..12).context("fixture too short")?)?; std::fs::write(&output, b"previous thumbnail")?; @@ -346,7 +351,7 @@ fn animation_output_budget_fails_before_writing_the_next_frame() -> Result<()> { let mut output = tempfile::tempfile()?; let length = super::super::UNTRUSTED_IMAGE_DECODER_MAX_ALLOC_BYTES - 1; output.set_len(length)?; - output.seek(SeekFrom::End(0))?; + let _position = output.seek(SeekFrom::End(0))?; let pixels = RgbaImage::from_pixel(4, 3, Rgba([1, 2, 3, 255])); ensure!( write_animation_frame(&mut output, &pixels, 100).is_err(), diff --git a/src/media/pdf.rs b/src/media/pdf.rs index 70c6157a..667d4a33 100644 --- a/src/media/pdf.rs +++ b/src/media/pdf.rs @@ -47,7 +47,7 @@ pub(crate) fn render(input: &Path, output: &Path, max_dim: u32) -> Result<()> { "PDF preview exceeds source budget" ); let mut bytes = Vec::new(); - source.take(MAX_PREVIEW_BYTES + 1).read_to_end(&mut bytes)?; + let _bytes_read = source.take(MAX_PREVIEW_BYTES + 1).read_to_end(&mut bytes)?; let source_length = preflight(&bytes)?; let pdf = Pdf::new(bytes).map_err(|error| anyhow::anyhow!("PDF parse failed: {error:?}"))?; ensure!( @@ -70,7 +70,10 @@ pub(crate) fn render(input: &Path, output: &Path, max_dim: u32) -> Result<()> { let pixel_height = (height * scale).round().clamp(1.0, f32::from(max_dim)) as u16; super::images::validate_dimensions(u32::from(pixel_width), u32::from(pixel_height))?; ensure!( - source_length * u64::from(pixel_width) * u64::from(pixel_height) <= MAX_PREVIEW_WORK, + source_length + .checked_mul(u64::from(pixel_width)) + .and_then(|work| work.checked_mul(u64::from(pixel_height))) + .is_some_and(|work| work <= MAX_PREVIEW_WORK), "PDF preview exceeds raster work budget" ); let warning = Arc::new(AtomicBool::new(false)); @@ -108,7 +111,7 @@ pub(crate) fn render(input: &Path, output: &Path, max_dim: u32) -> Result<()> { let mut temporary = tempfile::NamedTempFile::new_in(parent)?; image::DynamicImage::ImageRgba8(image) .write_to(temporary.as_file_mut(), image::ImageFormat::WebP)?; - temporary.persist(output).context("publish PDF preview")?; + drop(temporary.persist(output).context("publish PDF preview")?); Ok(()) } @@ -169,7 +172,7 @@ fn preflight_syntax(bytes: &[u8]) -> Result<()> { for value in token.split(|byte| b"/[]<>(){}%".contains(byte)) { if let Ok(number) = std::str::from_utf8(value).unwrap_or("").parse::() { ensure!( - number.is_finite() && number.abs() <= 1_000_000.0, + number.is_finite() && number.abs() <= 1_000_000.0_f64, "PDF numeric value exceeds safety budget" ); } @@ -262,7 +265,7 @@ fn preflight_page_tree(bytes: &[u8]) -> Result { "duplicate PDF objects require placeholder preview" ); } - let mut leaves = 0; + let mut leaves = 0_usize; for dict in objects.values() { if !dict.contains_key(b"Kids") { continue; @@ -271,7 +274,7 @@ fn preflight_page_tree(bytes: &[u8]) -> Result { anyhow::bail!("indirect PDF page lists require placeholder preview"); }; for kid in kids.raw_iter() { - leaves += 1; + leaves = leaves.checked_add(1).context("PDF page count overflow")?; ensure!(leaves <= MAX_PREVIEW_OBJECTS, "PDF page budget exceeded"); let reference = kid .as_obj_ref() @@ -304,7 +307,13 @@ fn preflight_stream(dict: &Dict<'_>, stream: &[u8], expanded_bytes: &mut u64) -> .context("PDF expanded content exceeds source budget")?; let mut inflater = flate2::bufread::ZlibDecoder::new(stream); let mut buffer = Vec::new(); - (&mut inflater).take(limit + 1).read_to_end(&mut buffer)?; + let _bytes_read = (&mut inflater) + .take( + limit + .checked_add(1) + .context("PDF expanded content limit overflow")?, + ) + .read_to_end(&mut buffer)?; ensure!( u64::try_from(buffer.len())? <= limit, "PDF expanded content exceeds source budget" @@ -343,7 +352,7 @@ fn preflight_content(program: &[u8]) -> Result<()> { reader.peek_byte(), Some(b'/' | b'.' | b'+' | b'-' | b'0'..=b'9' | b'[' | b'<' | b'(') ) { - reader + let _operand = reader .read_without_context::>() .context("invalid PDF content operand")?; } else { @@ -437,7 +446,7 @@ mod tests { ensure!(preflight(bytes).is_err(), "hostile PDF passed preflight"); } let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); - preflight(&std::fs::read(root.join("simple.pdf"))?)?; + let _expanded_bytes = preflight(&std::fs::read(root.join("simple.pdf"))?)?; Ok(()) } #[test] diff --git a/src/media/probe.rs b/src/media/probe.rs index df3afc5d..e49d641c 100644 --- a/src/media/probe.rs +++ b/src/media/probe.rs @@ -125,7 +125,10 @@ impl VideoInfo { let (width, height) = self.dimensions.context("output has no video dimensions")?; ensure!(width > 0 && height > 0, "output has zero video dimensions"); ensure!( - u64::from(width) * u64::from(height) <= super::MAX_UNTRUSTED_IMAGE_PIXELS, + u64::from(width) + .checked_mul(u64::from(height)) + .context("video frame area overflow")? + <= super::MAX_UNTRUSTED_IMAGE_PIXELS, "output video dimensions exceed the media pixel budget" ); if let Some(duration) = self.duration { @@ -146,7 +149,7 @@ pub(crate) fn inspect_video(path: &Path) -> Result { use symphonia::core::formats::well_known::{FORMAT_ID_ISOMP4, FORMAT_ID_MKV}; let format = open_format(path)?; let mut header = Vec::with_capacity(512); - File::open(path)?.take(512).read_to_end(&mut header)?; + let _bytes_read = File::open(path)?.take(512).read_to_end(&mut header)?; let container = match format.format_info().format { FORMAT_ID_MKV => match crate::utils::files::video_container_mime(&header)? { "video/webm" => VideoContainer::Webm, @@ -304,7 +307,8 @@ impl std::io::Read for ContainerSource { .get_mut(..allowed) .ok_or_else(|| std::io::Error::other("invalid container read buffer"))?, )?; - self.window + let _previous_count = self + .window .bytes .fetch_add(u64::try_from(count).unwrap_or(u64::MAX), Ordering::Relaxed); self.read_bytes = self @@ -481,7 +485,7 @@ pub fn inspect(path: &Path) -> Result { } } Some(CodecParameters::Audio(params)) => { - audio_codec.get_or_insert_with(|| audio_codec_name(params.codec)); + let _entry = audio_codec.get_or_insert_with(|| audio_codec_name(params.codec)); } _ => {} } @@ -562,7 +566,7 @@ fn video_track_codec( ); Ok(()) }) - .map(|codec| codec.unwrap_or("unknown")) + .map(|declared_codec| declared_codec.unwrap_or("unknown")) } /// Normalize common video codec declarations used by the upload/transcode paths. @@ -641,7 +645,7 @@ mod tests { ("video.mkv", VideoContainer::Matroska, "h264", true), ("video.mp4", VideoContainer::Mp4, "h264", true), ] { - std::fs::copy(root.join(name), &disguised)?; + let _bytes_copied = std::fs::copy(root.join(name), &disguised)?; let info = inspect_video(&disguised).with_context(|| format!("inspect {name}"))?; ensure!( info.container == container, @@ -758,7 +762,7 @@ mod tests { "video.mp4", "audio.webm", ] { - std::fs::copy(root.join(name), &output)?; + let _bytes_copied = std::fs::copy(root.join(name), &output)?; ensure!( validate_webm_output_file(&output).is_err(), "invalid output {name} accepted" @@ -771,7 +775,7 @@ mod tests { "partial output accepted" ); } - std::fs::copy(root.join("video.webm"), &output)?; + let _bytes_copied = std::fs::copy(root.join("video.webm"), &output)?; validate_webm_output_file(&output)?; Ok(()) } @@ -806,7 +810,7 @@ mod tests { window.bytes.load(Ordering::Relaxed) == MAX_PACKET_READ_BYTES, "read limit overshot" ); - let mut source = ContainerSource { + let mut cancelled_source = ContainerSource { file: tempfile::tempfile()?, length: 0, started: Instant::now(), @@ -818,17 +822,17 @@ mod tests { cancel.cancel(); let mut byte = [0]; ensure!( - source.read(&mut byte).is_err(), + cancelled_source.read(&mut byte).is_err(), "cancelled parser read continued" ); - source.cancel = None; - source.timeout = Duration::ZERO; - let error = source + cancelled_source.cancel = None; + cancelled_source.timeout = Duration::ZERO; + let deadline_error = cancelled_source .read(&mut byte) .err() .context("expired parser read continued")?; ensure!( - error.kind() == std::io::ErrorKind::TimedOut, + deadline_error.kind() == std::io::ErrorKind::TimedOut, "wrong deadline error" ); Ok(()) @@ -967,7 +971,7 @@ mod tests { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/media"); let dir = tempfile::tempdir()?; let misleading = dir.path().join("image.jpg"); - std::fs::copy(root.join("audio.webm"), &misleading)?; + let _bytes_copied = std::fs::copy(root.join("audio.webm"), &misleading)?; ensure!( inspect(&misleading)?.kind == StreamKind::AudioOnly, "extension overrode content" diff --git a/src/media/probe/speex.rs b/src/media/probe/speex.rs index 514cd2d0..f3956d66 100644 --- a/src/media/probe/speex.rs +++ b/src/media/probe/speex.rs @@ -83,7 +83,7 @@ pub(super) fn inspect( .try_into()?; if !streams.contains_key(&serial) { ensure!( - streams.values().all(|stream| stream.ended) + streams.values().all(|logical_stream| logical_stream.ended) || streams.values().all(LogicalStream::permits_bos), "Speex BOS pages are not grouped at a chain boundary" ); @@ -96,7 +96,7 @@ pub(super) fn inspect( "mixed-codec Speex container is not validated" ); let extra_headers = validate_identification(&page)?; - streams.insert( + let _previous_entry = streams.insert( serial, LogicalStream { extra_headers, @@ -127,7 +127,7 @@ pub(super) fn inspect( } if remaining == 0 { ensure!( - streams.values().all(|stream| stream.ended), + streams.values().all(|logical_stream| logical_stream.ended), "incomplete Speex container" ); break; @@ -242,11 +242,17 @@ impl Page { let mut lacing = vec![0; segments]; file.read_exact(&mut lacing)?; let size = lacing.iter().map(|size| usize::from(*size)).sum::(); - let total = 27 + u64::try_from(segments)? + u64::try_from(size)?; + let payload_size = u64::try_from(size)?; + let total = u64::try_from(segments)? + .checked_add(27) + .and_then(|header_size| header_size.checked_add(payload_size)) + .context("Ogg page size overflow")?; ensure!(total <= *remaining, "truncated Ogg payload"); let mut body = vec![0; size]; file.read_exact(&mut body)?; - *remaining -= total; + *remaining = remaining + .checked_sub(total) + .context("Ogg page exceeds remaining file")?; Ok(Self { header, lacing, @@ -284,9 +290,9 @@ impl Page { .chain(&self.lacing) .chain(&self.body) .fold(0_u32, |mut crc, byte| { - crc ^= u32::from(*byte) << 24; - for _ in 0..8 { - crc = (crc << 1) + crc ^= u32::from(*byte) << 24_i32; + for _ in 0_i32..8_i32 { + crc = (crc << 1_i32) ^ if crc & 0x8000_0000 != 0 { 0x04c1_1db7 } else { @@ -309,26 +315,31 @@ fn validate_identification(page: &Page) -> Result { let field = |offset: usize| -> Result { Ok(i32::from_le_bytes( page.body - .get(offset..offset + 4) + .get( + offset + ..offset + .checked_add(4) + .context("Speex field offset overflow")?, + ) .context("truncated Speex identification")? .try_into()?, )) }; let mode = field(40)?; - ensure!((0..=2).contains(&mode), "invalid Speex mode"); + ensure!((0_i32..=2_i32).contains(&mode), "invalid Speex mode"); let shift = u32::try_from(mode)?; ensure!( - field(28)? == 1 - && field(32)? == 80 + field(28)? == 1_i32 + && field(32)? == 80_i32 && field(36)? == 8000_i32.checked_shl(shift).context("Speex rate overflow")? - && (1..=4).contains(&field(44)?) - && (1..=2).contains(&field(48)?) + && (1_i32..=4_i32).contains(&field(44)?) + && (1_i32..=2_i32).contains(&field(48)?) && field(56)? == 160_i32.checked_shl(shift).context("Speex frame overflow")? - && (0..=1).contains(&field(60)?) - && (1..=64).contains(&field(64)?) - && (0..=8).contains(&field(68)?) - && field(72)? == 0 - && field(76)? == 0, + && (0_i32..=1_i32).contains(&field(60)?) + && (1_i32..=64_i32).contains(&field(64)?) + && (0_i32..=8_i32).contains(&field(68)?) + && field(72)? == 0_i32 + && field(76)? == 0_i32, "invalid Speex identification metadata" ); Ok(u32::try_from(field(68)?)?) diff --git a/src/media/process.rs b/src/media/process.rs index 733fa788..f9d22d8f 100644 --- a/src/media/process.rs +++ b/src/media/process.rs @@ -16,7 +16,7 @@ pub(crate) fn configure_std_command(command: &mut Command) -> &mut Command { #[cfg(unix)] { use std::os::unix::process::CommandExt as _; - command.process_group(0); + let _configured_command = command.process_group(0); } command } @@ -29,7 +29,7 @@ pub(crate) fn run_std_command_with_timeout( spawn_context: impl FnOnce() -> String, io_context: impl Fn() -> String, ) -> Result { - configure_std_command(command); + let _configured_command = configure_std_command(command); let mut child = command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -52,12 +52,12 @@ pub(crate) fn run_std_command_with_timeout( loop { if let Some(status) = child.try_wait()? { process_group.terminate_remaining(); - let stdout = join_pipe_reader(stdout_reader).with_context(&io_context)?; - let stderr = join_pipe_reader(stderr_reader).with_context(&io_context)?; + let stdout_bytes = join_pipe_reader(stdout_reader).with_context(&io_context)?; + let stderr_bytes = join_pipe_reader(stderr_reader).with_context(&io_context)?; return Ok(Output { status, - stdout, - stderr, + stdout: stdout_bytes, + stderr: stderr_bytes, }); } if started.elapsed() >= timeout { @@ -98,9 +98,21 @@ fn read_pipe(mut pipe: impl std::io::Read) -> std::io::Result> { fn join_pipe_reader( reader: std::thread::JoinHandle>>, ) -> std::io::Result> { - reader - .join() - .map_err(|_| std::io::Error::other("media output reader panicked"))? + reader.join().map_err(|panic| { + std::io::Error::other(format!( + "media output reader panicked: {}", + panic_message(panic.as_ref()) + )) + })? +} + +/// Recover a thread panic message while preserving ordinary string payloads. +pub(crate) fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload") } /// Owns the process-group cleanup obligation for one spawned command. @@ -135,17 +147,44 @@ impl Drop for ProcessGroupGuard { #[cfg(unix)] /// Terminates the Unix process group whose leader has `pid`. fn terminate_process_group(pid: u32) -> std::io::Result<()> { + terminate_process_group_using(pid, std::path::Path::new("/bin/kill")) +} + +#[cfg(unix)] +/// Uses the external kill program, falling back to the shell only if it is absent. +fn terminate_process_group_using(pid: u32, kill_program: &std::path::Path) -> std::io::Result<()> { let group = format!("-{pid}"); - let status = Command::new("/bin/kill") + match Command::new(kill_program) // GNU kill requires `--` before a negative process-group identifier; // BSD kill accepts the same portable form. .args(["-KILL", "--", &group]) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status()?; + .status() + { + Ok(_status) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Minimal Unix images can provide only the POSIX shell builtin. + // The script is constant; the derived numeric group is a separate + // positional argument and is never interpolated into shell code. + let _status = Command::new("/bin/sh") + // The builtin needs no environment; imported shell functions + // or startup hooks must not replace the intended command. + .env_clear() + .args([ + "-c", + "kill -s KILL -- \"$1\"", + "rustchan-process-group", + &group, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + } + Err(error) => return Err(error), + } // A nonzero result normally means the group already exited between wait // and cleanup. Direct-child termination remains the portable fallback. - let _ = status; Ok(()) } @@ -155,12 +194,17 @@ fn terminate_process_group(_pid: u32) -> std::io::Result<()> { Ok(()) } -#[cfg(all(test, unix))] +#[cfg(test)] +#[cfg(unix)] mod tests { - use super::{run_std_command_with_timeout, MEDIA_SUBPROCESS_OUTPUT_LIMIT_BYTES}; + use super::{ + configure_std_command, run_std_command_with_timeout, terminate_process_group_using, + ProcessGroupGuard, MEDIA_SUBPROCESS_OUTPUT_LIMIT_BYTES, + }; use crate::workers::{wait_for_ffmpeg_output, AsyncWaitOutcome}; use anyhow::{Context as _, Result}; use std::os::unix::fs::PermissionsExt as _; + use std::os::unix::process::ExitStatusExt as _; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -182,7 +226,9 @@ wait \"$child\"\n"; } fn read_wrapper_pids(pid_file: &Path) -> Result<(i32, i32)> { - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + .checked_add(Duration::from_secs(2)) + .context("process test deadline overflow")?; loop { if let Ok(contents) = std::fs::read_to_string(pid_file) { let mut parts = contents.split_whitespace(); @@ -213,7 +259,9 @@ wait \"$child\"\n"; } fn assert_processes_gone(parent: i32, child: i32) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + .checked_add(Duration::from_secs(2)) + .context("process test deadline overflow")?; while (process_exists(parent) || process_exists(child)) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } @@ -228,11 +276,94 @@ wait \"$child\"\n"; Ok(()) } + /// A missing kill executable still terminates the group under hostile shell inheritance. + #[test] + fn missing_kill_executable_terminates_parent_and_descendant() -> Result<()> { + const CHILD_MARKER: &str = "RUSTCHAN_PROCESS_FALLBACK_ENVIRONMENT_TEST_CHILD"; + if std::env::var_os(CHILD_MARKER).as_deref() != Some(std::ffi::OsStr::new("1")) { + let (_, test_module) = module_path!() + .split_once("::") + .context("fallback test module omitted its crate prefix")?; + let test_filter = + format!("{test_module}::missing_kill_executable_terminates_parent_and_descendant"); + // Only the child receives the exported function; the parent test + // runner and every concurrently executing test keep their environment. + let output = std::process::Command::new( + std::env::current_exe().context("locate fallback regression test binary")?, + ) + .args(["--exact", &test_filter, "--nocapture", "--format=pretty"]) + .env(CHILD_MARKER, "1") + .env("BASH_FUNC_kill%%", "() { return 0; }") + .output() + .context("run fallback regression with an exported shell function")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::ensure!( + stdout.lines().any(|line| line.trim() == "running 1 test"), + "child must run exactly the fallback regression; stdout:\n{stdout}\nstderr:\n{stderr}" + ); + anyhow::ensure!( + output.status.success() + && stdout.lines().any(|line| { + line.starts_with("test result: ok. 1 passed; 0 failed; 0 ignored;") + }), + "fallback child failed with {}: stdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); + return Ok(()); + } + let (temp_dir, wrapper, pid_file) = process_wrapper()?; + let missing_kill = temp_dir.path().join("missing-kill"); + let mut command = std::process::Command::new("/bin/sh"); + let _configured_command = command.args([wrapper.as_os_str(), pid_file.as_os_str()]); + let _configured_group = configure_std_command(&mut command); + let mut child = command.spawn().context("spawn shell-fallback wrapper")?; + let mut process_group = ProcessGroupGuard::new(Some(child.id())); + + let result = (|| -> Result<()> { + let (parent, descendant) = read_wrapper_pids(&pid_file)?; + anyhow::ensure!( + parent == i32::try_from(child.id()).context("wrapper PID exceeds i32")?, + "wrapper parent must be the owned process-group leader" + ); + anyhow::ensure!( + process_exists(parent) && process_exists(descendant), + "the parent and descendant must be alive before fallback termination" + ); + anyhow::ensure!( + !missing_kill.exists(), + "fixture kill executable must be absent" + ); + terminate_process_group_using(child.id(), &missing_kill)?; + let deadline = Instant::now() + .checked_add(Duration::from_secs(2)) + .context("shell-fallback exit deadline overflow")?; + let status = loop { + if let Some(status) = child.try_wait().context("reap shell-fallback wrapper")? { + break status; + } + anyhow::ensure!( + Instant::now() < deadline, + "shell-fallback wrapper did not exit" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + anyhow::ensure!(status.signal() == Some(9_i32), "fallback must send SIGKILL"); + assert_processes_gone(parent, descendant) + })(); + + // Always terminate and reap this test's owned fixture on an error too. + process_group.terminate_remaining(); + drop(child.kill()); + drop(child.wait()); + result + } + #[test] fn blocking_timeout_kills_parent_and_descendant() -> Result<()> { let (_temp_dir, wrapper, pid_file) = process_wrapper()?; let mut command = std::process::Command::new("/bin/sh"); - command.args([wrapper.as_os_str(), pid_file.as_os_str()]); + let _configured_command = command.args([wrapper.as_os_str(), pid_file.as_os_str()]); let error = run_std_command_with_timeout( &mut command, @@ -243,16 +374,16 @@ wait \"$child\"\n"; ) .err() .context("wrapper unexpectedly completed")?; - let (parent, child) = read_wrapper_pids(&pid_file)?; + let (parent, descendant) = read_wrapper_pids(&pid_file)?; anyhow::ensure!(error.to_string().contains("timed out")); - assert_processes_gone(parent, child) + assert_processes_gone(parent, descendant) } #[test] fn blocking_subprocess_output_is_retained_within_limit() -> Result<()> { let mut command = std::process::Command::new("/bin/sh"); - command.args(["-c", "printf '%131072s' ''"]); + let _configured_command = command.args(["-c", "printf '%131072s' ''"]); let output = run_std_command_with_timeout( &mut command, @@ -272,30 +403,30 @@ wait \"$child\"\n"; async fn async_timeout_kills_parent_and_descendant() -> Result<()> { let (_temp_dir, wrapper, pid_file) = process_wrapper()?; let mut command = tokio::process::Command::new("/bin/sh"); - command + let _configured_command = command .args([wrapper.as_os_str(), pid_file.as_os_str()]) - .kill_on_drop(true); - command.process_group(0); + .kill_on_drop(true) + .process_group(0); let child = command.spawn().context("spawn async test wrapper")?; let outcome = wait_for_ffmpeg_output(child, Duration::from_millis(100), CancellationToken::new()) .await?; - let (parent, child) = read_wrapper_pids(&pid_file)?; + let (parent, descendant) = read_wrapper_pids(&pid_file)?; anyhow::ensure!(matches!(outcome, AsyncWaitOutcome::TimedOut)); - assert_processes_gone(parent, child) + assert_processes_gone(parent, descendant) } #[tokio::test] async fn async_subprocess_output_is_retained_within_limit() -> Result<()> { let mut command = tokio::process::Command::new("/bin/sh"); - command + let _configured_command = command .args(["-c", "printf '%131072s' '' >&2"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - command.process_group(0); + .kill_on_drop(true) + .process_group(0); let child = command .spawn() .context("spawn verbose async test wrapper")?; @@ -316,45 +447,46 @@ wait \"$child\"\n"; async fn cancellation_kills_parent_and_descendant() -> Result<()> { let (_temp_dir, wrapper, pid_file) = process_wrapper()?; let mut command = tokio::process::Command::new("/bin/sh"); - command + let _configured_command = command .args([wrapper.as_os_str(), pid_file.as_os_str()]) - .kill_on_drop(true); - command.process_group(0); + .kill_on_drop(true) + .process_group(0); let child = command.spawn().context("spawn cancellable test wrapper")?; let cancel = CancellationToken::new(); let cancel_trigger = cancel.clone(); - tokio::spawn(async move { + let cancel_task = tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(100)).await; cancel_trigger.cancel(); }); let outcome = wait_for_ffmpeg_output(child, Duration::from_secs(10), cancel).await?; - let (parent, child) = read_wrapper_pids(&pid_file)?; + cancel_task.await.context("join cancellation trigger")?; + let (parent, descendant) = read_wrapper_pids(&pid_file)?; anyhow::ensure!(matches!(outcome, AsyncWaitOutcome::Cancelled)); - assert_processes_gone(parent, child) + assert_processes_gone(parent, descendant) } #[tokio::test] async fn repeated_timeouts_do_not_accumulate_descendants() -> Result<()> { let (_temp_dir, wrapper, _) = process_wrapper()?; - for attempt in 1..=3 { + for attempt in 1_i32..=3_i32 { let pid_file = wrapper.with_file_name(format!("pids-{attempt}.txt")); let mut command = tokio::process::Command::new("/bin/sh"); - command + let _configured_command = command .args([wrapper.as_os_str(), pid_file.as_os_str()]) - .kill_on_drop(true); - command.process_group(0); + .kill_on_drop(true) + .process_group(0); let child = command.spawn().context("spawn retry test wrapper")?; let outcome = wait_for_ffmpeg_output(child, Duration::from_millis(100), CancellationToken::new()) .await?; - let (parent, child) = read_wrapper_pids(&pid_file)?; + let (parent, descendant) = read_wrapper_pids(&pid_file)?; anyhow::ensure!(matches!(outcome, AsyncWaitOutcome::TimedOut)); - assert_processes_gone(parent, child)?; + assert_processes_gone(parent, descendant)?; } Ok(()) } diff --git a/src/media/prune.rs b/src/media/prune.rs index 6b52451b..1d15c6ce 100644 --- a/src/media/prune.rs +++ b/src/media/prune.rs @@ -352,7 +352,7 @@ fn group_shared_candidates(posts: &[PostCandidate]) -> Vec { post_ids.push(post.post_id); created_at = created_at.min(post.created_at); for path in &post.paths { - unique_paths + let _entry = unique_paths .entry(path.path.clone()) .or_insert_with(|| path.clone()); } @@ -662,7 +662,7 @@ fn validate_prune_payload(payload: &OriginalPrunePayload) -> Result<()> { anyhow::bail!("original-prune payload contains duplicate posts or paths"); } for path in &payload.paths { - validate_post_original_path(&path.path, &path.board_short) + let _validated_path = validate_post_original_path(&path.path, &path.board_short) .ok_or_else(|| anyhow::anyhow!("unsafe original-prune path {:?}", path.path))?; } Ok(()) @@ -731,7 +731,7 @@ fn finalize_original_prune_payload_in_tx( match state.as_deref() { None | Some(crate::db::MEDIA_ORIGINAL_PRUNED) => {} Some(crate::db::MEDIA_ORIGINAL_PRUNE_PENDING) => { - conn.execute( + let _rows_affected = conn.execute( "UPDATE posts SET media_processing_state = ?1, media_processing_error = ?2 WHERE id = ?3 AND media_processing_state = ?4", @@ -762,7 +762,8 @@ fn finalize_original_prune_payload_in_tx( .optional()? .is_some(); if !still_required { - conn.execute("DELETE FROM file_hashes WHERE file_path = ?1", [&path.path])?; + let _rows_affected = + conn.execute("DELETE FROM file_hashes WHERE file_path = ?1", [&path.path])?; } } crate::db::delete_pending_fs_op(conn, pending_op_id)?; @@ -841,7 +842,6 @@ fn validate_current_target_paths( #[cfg(test)] mod tests { use super::*; - use anyhow::Result; struct MediaPostFixture<'a> { board_id: i64, @@ -857,7 +857,7 @@ mod tests { conn: &rusqlite::Connection, fixture: &MediaPostFixture<'_>, ) -> Result<()> { - conn.execute( + let _rows_affected = conn.execute( "INSERT INTO posts ( id, thread_id, board_id, name, body, body_html, file_path, file_name, file_size, thumb_path, mime_type, deletion_token, @@ -1127,7 +1127,7 @@ mod tests { file_size: 4, }, )?; - conn.execute( + let _rows_affected = conn.execute( "UPDATE posts SET audio_file_path = 'b/track.flac', audio_file_name = 'track.flac', @@ -1213,7 +1213,7 @@ mod tests { assert_eq!(post_state(&conn, 101)?, ""); assert_eq!(post_state(&conn, 102)?, ""); - conn.execute( + let _rows_affected = conn.execute( "UPDATE threads SET archived = 0 WHERE id = ?1", [archived_thread_id], )?; @@ -1299,7 +1299,7 @@ mod tests { file_size: 4, }, )?; - conn.execute( + let _rows_affected = conn.execute( "UPDATE posts SET audio_file_path = 'b/audio.flac', audio_file_size = 6 WHERE id = 101", @@ -1327,9 +1327,12 @@ mod tests { let after_second = std::fs::read_dir(dir.path().join("b"))? .filter_map(std::result::Result::ok) .count(); - let conn = pool.get()?; - assert_eq!(post_state(&conn, 101)?, crate::db::MEDIA_ORIGINAL_PRUNED); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + let verification_conn = pool.get()?; + assert_eq!( + post_state(&verification_conn, 101)?, + crate::db::MEDIA_ORIGINAL_PRUNED + ); + assert!(crate::db::list_pending_fs_ops(&verification_conn)?.is_empty()); assert!(!dir.path().join("b/image.webp").exists()); assert!(!dir.path().join("b/audio.flac").exists()); assert_eq!( @@ -1390,9 +1393,12 @@ mod tests { &pool, dir.path().to_str().context("UTF-8 path")?, )?; - let conn = pool.get()?; - assert_eq!(post_state(&conn, 101)?, crate::db::MEDIA_ORIGINAL_PRUNED); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + let verification_conn = pool.get()?; + assert_eq!( + post_state(&verification_conn, 101)?, + crate::db::MEDIA_ORIGINAL_PRUNED + ); + assert!(crate::db::list_pending_fs_ops(&verification_conn)?.is_empty()); Ok(()) } @@ -1419,23 +1425,25 @@ mod tests { file_size: 8, }, )?; - pending_prune_intent(&conn, dir.path())?; - conn.execute("DELETE FROM posts WHERE id = 101", [])?; + let _persisted_intent = pending_prune_intent(&conn, dir.path())?; + let _rows_affected = conn.execute("DELETE FROM posts WHERE id = 101", [])?; drop(conn); crate::pending_fs::reconcile_pending_fs_ops( &pool, dir.path().to_str().context("UTF-8 path")?, )?; - let conn = pool.get()?; + let verification_conn = pool.get()?; assert_eq!( - conn.query_row("SELECT COUNT(*) FROM posts WHERE id = 101", [], |row| { - row.get::<_, i64>(0) - })?, + verification_conn.query_row( + "SELECT COUNT(*) FROM posts WHERE id = 101", + [], + |row| { row.get::<_, i64>(0) } + )?, 0 ); assert!(!dir.path().join("b/file.webp").exists()); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + assert!(crate::db::list_pending_fs_ops(&verification_conn)?.is_empty()); Ok(()) } @@ -1486,14 +1494,17 @@ mod tests { "new normal reference must fail deletion closed" ); assert_eq!(post_state(&conn, 102)?, ""); - conn.execute("DELETE FROM posts WHERE id = 102", [])?; + let _rows_affected = conn.execute("DELETE FROM posts WHERE id = 102", [])?; drop(conn); crate::pending_fs::reconcile_pending_fs_ops(&pool, upload_dir)?; - let conn = pool.get()?; - assert_eq!(post_state(&conn, 101)?, crate::db::MEDIA_ORIGINAL_PRUNED); + let verification_conn = pool.get()?; + assert_eq!( + post_state(&verification_conn, 101)?, + crate::db::MEDIA_ORIGINAL_PRUNED + ); assert!(!dir.path().join("b/file.webp").exists()); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + assert!(crate::db::list_pending_fs_ops(&verification_conn)?.is_empty()); Ok(()) } @@ -1520,8 +1531,8 @@ mod tests { file_size: 8, }, )?; - pending_prune_intent(&conn, dir.path())?; - conn.execute( + let _persisted_intent = pending_prune_intent(&conn, dir.path())?; + let _rows_affected = conn.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json, created_at) VALUES ('malformed-prune', 'original_prune', '{', unixepoch() - 10)", [], @@ -1533,10 +1544,13 @@ mod tests { dir.path().to_str().context("UTF-8 path")?, ); assert!(result.is_err(), "malformed entry remains fail-closed"); - let conn = pool.get()?; - assert_eq!(post_state(&conn, 101)?, crate::db::MEDIA_ORIGINAL_PRUNED); + let verification_conn = pool.get()?; + assert_eq!( + post_state(&verification_conn, 101)?, + crate::db::MEDIA_ORIGINAL_PRUNED + ); assert!(!dir.path().join("b/file.webp").exists()); - let pending = crate::db::list_pending_fs_ops(&conn)?; + let pending = crate::db::list_pending_fs_ops(&verification_conn)?; assert_eq!(pending.len(), 1); assert_eq!( pending.first().map(|op| op.id.as_str()), @@ -1573,22 +1587,25 @@ mod tests { let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); let upload_dir = dir.path().to_str().context("UTF-8 path")?.to_owned(); let mut handles = Vec::new(); - for _ in 0..2 { + for _ in 0_i32..2_i32 { let thread_pool = pool.clone(); let thread_barrier = std::sync::Arc::clone(&barrier); let thread_upload_dir = upload_dir.clone(); handles.push(std::thread::spawn(move || -> Result { - let conn = thread_pool.get()?; - thread_barrier.wait(); - prune_to_limit(&conn, &thread_upload_dir, 0) + let worker_conn = thread_pool.get()?; + let _barrier_state = thread_barrier.wait(); + prune_to_limit(&worker_conn, &thread_upload_dir, 0) })); } let reports = handles .into_iter() .map(|handle| { - handle - .join() - .map_err(|_| anyhow::anyhow!("prune thread panicked"))? + handle.join().map_err(|panic| { + anyhow::anyhow!( + "prune thread panicked: {}", + super::super::process::panic_message(panic.as_ref()) + ) + })? }) .collect::>>()?; @@ -1600,9 +1617,12 @@ mod tests { 1, "the physical file is removed exactly once" ); - let conn = pool.get()?; - assert_eq!(post_state(&conn, 101)?, crate::db::MEDIA_ORIGINAL_PRUNED); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + let verification_conn = pool.get()?; + assert_eq!( + post_state(&verification_conn, 101)?, + crate::db::MEDIA_ORIGINAL_PRUNED + ); + assert!(crate::db::list_pending_fs_ops(&verification_conn)?.is_empty()); assert!(!dir.path().join("b/file.webp").exists()); Ok(()) } diff --git a/src/media/reconcile.rs b/src/media/reconcile.rs index 164614c3..833ec9c5 100644 --- a/src/media/reconcile.rs +++ b/src/media/reconcile.rs @@ -684,7 +684,7 @@ fn parse_managed_path( let components = path .components() .map(|component| match component { - Component::Normal(value) => value.to_str(), + Component::Normal(component_value) => component_value.to_str(), Component::CurDir | Component::ParentDir | Component::RootDir @@ -869,7 +869,7 @@ fn media_job_key(job_type: &str, parsed: &ParsedJob) -> Option { board, source, output, - .. + category: _, } = parsed else { return None; @@ -1122,13 +1122,13 @@ fn collect_boards( let folded = board.to_ascii_lowercase(); if let Some(other) = casefold.insert(folded.clone(), board.clone()) { if other != board { - model.case_conflict_boards.insert(other.clone()); - model.case_conflict_boards.insert(board.clone()); + let _other_conflict_inserted = model.case_conflict_boards.insert(other.clone()); + let _board_conflict_inserted = model.case_conflict_boards.insert(board.clone()); model.global_ambiguity = true; } } - model.boards_by_id.insert(board_id, board.clone()); - model.boards.insert(board); + let _previous_board_name = model.boards_by_id.insert(board_id, board.clone()); + let _board_inserted = model.boards.insert(board); } Ok(()) } @@ -1172,7 +1172,7 @@ fn collect_posts( if model.boards_by_id.get(&board_id) != Some(&board) { model.global_ambiguity = true; } - model.posts.insert( + let _previous_entry = model.posts.insert( post_id, PostMediaState { board: board.clone(), @@ -1291,7 +1291,7 @@ fn collect_hashes( mark_cross_board_or_malformed(model, &file_path, None, owner, MediaCategory::Original); continue; }; - digests_by_path + let _previous_entry = digests_by_path .entry(file.relative.clone()) .or_default() .insert(digest.clone()); @@ -1686,7 +1686,7 @@ fn collect_intent( .ok_or_else(|| { unsafe_intent_path("board-restore intent has no trusted board target") })?; - model.scheduled_boards.insert(board.to_owned()); + let _previous_entry = model.scheduled_boards.insert(board.to_owned()); model.global_ambiguity = true; model.findings.push(AuditFinding { classification: AuditClassification::LifecycleInProgress, @@ -1720,7 +1720,7 @@ fn collect_delete_intent( "delete-files intent contains an unsafe board directory", )); } - model.scheduled_boards.insert(board.clone()); + let _previous_entry = model.scheduled_boards.insert(board.clone()); } Ok(()) } @@ -1826,7 +1826,9 @@ fn collect_upload_intent( } let stage_relative = normalized_stage .strip_prefix(&root) - .map_err(|_| unsafe_intent_path("upload-finalize stage is outside upload root"))? + .map_err(|error| { + unsafe_intent_path("upload-finalize stage is outside upload root").context(error) + })? .to_str() .context("upload-finalize stage is non-UTF-8")?; if payload.relative_paths.is_empty() { @@ -2445,7 +2447,7 @@ impl<'a> InventoryWalker<'a> { InventoryEntryKind::UnsafePath, ); } else if metadata.is_dir() - && board_utf8.is_some_and(|board| self.model.boards.contains(board)) + && board_utf8.is_some_and(|board_name| self.model.boards.contains(board_name)) { self.walk_pending_board(&path, board_utf8.unwrap_or_default()); } else { @@ -2723,9 +2725,9 @@ fn audit_inventory( let category = path_category_from_claims(model, relative, entry.category); let required_claims = model.required_claims(relative).collect::>(); if let Some(claim) = required_claims.first() { - let classification = if required_claims + let reference_classification = if required_claims .iter() - .any(|claim| claim.role == ReferenceRole::Temporary) + .any(|required_claim| required_claim.role == ReferenceRole::Temporary) { AuditClassification::LifecycleInProgress } else { @@ -2745,7 +2747,7 @@ fn audit_inventory( model, report, &claimed_entry, - classification, + reference_classification, identity.size, example_limit, ); @@ -3202,7 +3204,7 @@ fn repair_orphan( let identity_changed = current.as_ref().map(|(_, identity)| identity) != Some(&candidate.identity); let digest_changed = match current.as_ref() { - Some((path, _)) => sha256_regular_file(path)? != expected_digest, + Some((current_path, _)) => sha256_regular_file(current_path)? != expected_digest, None => true, }; if acquired_reference || identity_changed || digest_changed { @@ -3302,7 +3304,7 @@ fn repair_obsolete_job( board, source, output, - .. + category: _, } = parse_job(&job_type, &payload)? else { return Ok(RepairAttempt::Conflict); @@ -3434,7 +3436,13 @@ fn path_has_lifecycle_claim( .collect::>>()?; for (job_type, payload) in jobs { match parse_job(&job_type, &payload) { - Ok(ParsedJob::Media { source, output, .. }) if source == path || output == path => { + Ok(ParsedJob::Media { + source, + output, + post_id: _, + board: _, + category: _, + }) if source == path || output == path => { return Ok(true); } Ok(_) => {} @@ -3623,30 +3631,38 @@ pub fn reconcile_managed_media( } fn update_metrics(report: &AuditReport) { - FILES_SCANNED_TOTAL.fetch_add(report.paths_examined, Ordering::Relaxed); - REFERENCES_SCANNED_TOTAL.fetch_add(report.references_examined, Ordering::Relaxed); + let _previous_files_scanned_total = + FILES_SCANNED_TOTAL.fetch_add(report.paths_examined, Ordering::Relaxed); + let _previous_references_scanned_total = + REFERENCES_SCANNED_TOTAL.fetch_add(report.references_examined, Ordering::Relaxed); let missing = report .counts .iter() .filter(|(classification, _)| classification.is_missing()) .fold(0_u64, |sum, (_, count)| sum.saturating_add(*count)); - MISSING_REFERENCES_TOTAL.fetch_add(missing, Ordering::Relaxed); + let _previous_missing_references_total = + MISSING_REFERENCES_TOTAL.fetch_add(missing, Ordering::Relaxed); let orphan_bytes = report .bytes_by_classification .get(&AuditClassification::SafeOrphanCandidate) .copied() .unwrap_or_default(); - SAFE_ORPHAN_BYTES_TOTAL.fetch_add(orphan_bytes, Ordering::Relaxed); + let _previous_safe_orphan_bytes_total = + SAFE_ORPHAN_BYTES_TOTAL.fetch_add(orphan_bytes, Ordering::Relaxed); let ambiguous = report .counts .get(&AuditClassification::AmbiguousOrphan) .copied() .unwrap_or_default(); - AMBIGUOUS_FILES_TOTAL.fetch_add(ambiguous, Ordering::Relaxed); - REPAIRS_TOTAL.fetch_add(report.repairs.completed(), Ordering::Relaxed); - REPAIR_CONFLICTS_TOTAL.fetch_add(report.repairs.revalidation_conflicts, Ordering::Relaxed); + let _previous_ambiguous_files_total = + AMBIGUOUS_FILES_TOTAL.fetch_add(ambiguous, Ordering::Relaxed); + let _previous_repairs_total = + REPAIRS_TOTAL.fetch_add(report.repairs.completed(), Ordering::Relaxed); + let _previous_repair_conflicts_total = + REPAIR_CONFLICTS_TOTAL.fetch_add(report.repairs.revalidation_conflicts, Ordering::Relaxed); if report.incomplete { - INCOMPLETE_SCANS_TOTAL.fetch_add(1, Ordering::Relaxed); + let _previous_incomplete_scans_total = + INCOMPLETE_SCANS_TOTAL.fetch_add(1, Ordering::Relaxed); } } @@ -3730,13 +3746,13 @@ mod tests { std::fs::create_dir_all(root.path().join("b/thumbs"))?; let pool = crate::db::init_test_pool()?; let conn = pool.get()?; - conn.execute( + let _inserted_boards = conn.execute( "INSERT INTO boards (short_name, name, description) VALUES ('b', 'Board', '')", [], )?; let board_id = conn.last_insert_rowid(); - conn.execute( + let _inserted_threads = conn.execute( "INSERT INTO threads (board_id, subject) VALUES (?1, 'thread')", [board_id], )?; @@ -3771,7 +3787,7 @@ mod tests { state: &str, ) -> Result { let conn = self.pool.get()?; - conn.execute( + let _rows_affected = conn.execute( "INSERT INTO posts (thread_id, board_id, name, body, body_html, deletion_token, file_path, thumb_path, mime_type, media_processing_state) @@ -3782,7 +3798,7 @@ mod tests { } fn hash(&self, digest: &str, file: &str, thumb: &str, mime: &str) -> Result<()> { - self.pool.get()?.execute( + let _rows_affected = self.pool.get()?.execute( "INSERT INTO file_hashes (sha256, file_path, thumb_path, mime_type) VALUES (?1, ?2, ?3, ?4)", params![digest, file, thumb, mime], @@ -3791,7 +3807,7 @@ mod tests { } fn job(&self, job_type: &str, payload: &serde_json::Value, status: &str) -> Result<()> { - self.pool.get()?.execute( + let _rows_affected = self.pool.get()?.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES (?1, ?2, ?3)", params![job_type, payload.to_string(), status], )?; @@ -3799,7 +3815,7 @@ mod tests { } fn intent(&self, id: &str, kind: &str, payload_json: &str) -> Result<()> { - self.pool.get()?.execute( + let _rows_affected = self.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES (?1, ?2, ?3)", params![id, kind, payload_json], )?; @@ -3871,8 +3887,8 @@ mod tests { "b/thumbs/shared.webp", "image/webp", )?; - for _ in 0..2 { - fixture.post( + for _ in 0_i32..2_i32 { + let _post_id = fixture.post( Some("b/shared.webp"), Some("b/thumbs/shared.webp"), Some("image/webp"), @@ -3881,7 +3897,7 @@ mod tests { } fixture.write("b/thumbs/pruned.webp", b"retained")?; - fixture.post( + let _post_id = fixture.post( Some("b/pruned.webp"), Some("b/thumbs/pruned.webp"), Some("image/webp"), @@ -4003,7 +4019,7 @@ mod tests { AuditClassification::SafeOrphanCandidate ) == 0 ); - fixture + let _removed_first_post = fixture .pool .get()? .execute("DELETE FROM posts WHERE id = ?1", [first_post])?; @@ -4013,7 +4029,7 @@ mod tests { AuditClassification::SafeOrphanCandidate ) == 0 ); - fixture + let _removed_second_post = fixture .pool .get()? .execute("DELETE FROM posts WHERE id = ?1", [second_post])?; @@ -4023,7 +4039,7 @@ mod tests { AuditClassification::SafeOrphanCandidate ) == 0 ); - fixture.pool.get()?.execute( + let _removed_shared_hashes = fixture.pool.get()?.execute( "DELETE FROM file_hashes WHERE sha256 = ?1", [&shared_digest], )?; @@ -4080,7 +4096,7 @@ mod tests { let fixture = Fixture::new()?; fixture.write("b/race.webp", b"race")?; let audited = candidate(&fixture, "b/race.webp")?; - fixture.post(Some("b/race.webp"), None, Some("image/webp"), "")?; + let _post_id = fixture.post(Some("b/race.webp"), None, Some("image/webp"), "")?; let conn = fixture.pool.get()?; let attempt = repair_orphan(&conn, fixture.root.path(), &audited)?; @@ -4110,8 +4126,11 @@ mod tests { let attempt = repair_orphan(&conn, fixture.root.path(), &audited)?; ensure!(attempt == RepairAttempt::Conflict); ensure!(fixture.pending_ops()?.is_empty()); - let conn = fixture.pool.get()?; - ensure!(direct_post_reference_exists(&conn, "b/trigger-race.webp")?); + let verification_conn = fixture.pool.get()?; + ensure!(direct_post_reference_exists( + &verification_conn, + "b/trigger-race.webp" + )?); ensure!(fixture.root.path().join("b/trigger-race.webp").exists()); Ok(()) } @@ -4119,20 +4138,20 @@ mod tests { #[test] fn missing_media_and_pruned_thumbnail_receive_conservative_classifications() -> Result<()> { let fixture = Fixture::new()?; - fixture.post( + let _missing_image_post = fixture.post( Some("b/missing.webp"), Some("b/thumbs/missing.webp"), Some("image/webp"), "", )?; fixture.write("b/thumbs/pruned.webp", b"retained")?; - fixture.post( + let _pruned_image_post = fixture.post( Some("b/pruned.webp"), Some("b/thumbs/pruned.webp"), Some("image/webp"), crate::db::MEDIA_ORIGINAL_PRUNED, )?; - fixture.post( + let _audio_post = fixture.post( Some("b/audio.mp3"), Some("b/thumbs/audio.png"), Some("audio/mpeg"), @@ -4152,7 +4171,7 @@ mod tests { "board_short": "b" } }); - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('video_transcode', ?1, 'pending')", [video_job.to_string()], @@ -4175,7 +4194,7 @@ mod tests { fixture.hash(&digest(b"missing"), "b/missing.webp", "", "image/webp")?; fixture.write("b/shared.webp", b"shared")?; fixture.hash(&digest(b"shared"), "b/shared.webp", "", "image/webp")?; - fixture.post(Some("b/shared.webp"), None, Some("image/webp"), "")?; + let _post_id = fixture.post(Some("b/shared.webp"), None, Some("image/webp"), "")?; let report = fixture.audit(ReconcileMode::Repair, limits())?; ensure!(report.repairs.stale_hash_rows_removed == 1); @@ -4200,7 +4219,7 @@ mod tests { fn conflicting_and_cross_board_hash_metadata_remains_quarantined() -> Result<()> { let fixture = Fixture::new()?; std::fs::create_dir_all(fixture.root.path().join("c/thumbs"))?; - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO boards (short_name, name, description) VALUES ('c', 'Other', '')", [], )?; @@ -4272,7 +4291,7 @@ mod tests { fn malformed_intent_makes_apparent_orphan_ambiguous() -> Result<()> { let fixture = Fixture::new()?; fixture.write("b/orphan.webp", b"orphan")?; - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('bad', 'delete_files', '{not-json')", [], @@ -4295,7 +4314,7 @@ mod tests { paths: vec!["/tmp/outside-rustchan".to_owned()], dirs: Vec::new(), }; - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('external', 'delete_files', ?1)", [serde_json::to_string(&payload)?], @@ -4323,17 +4342,17 @@ mod tests { }) .to_string(); let conn = fixture.pool.get()?; - conn.execute( + let _inserted_completed_jobs = conn.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('video_transcode', ?1, 'done')", [&job], )?; - conn.execute( + let _inserted_pending_jobs = conn.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('video_transcode', ?1, 'pending')", [&job], )?; - conn.execute( + let _inserted_malformed_jobs = conn.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('video_transcode', '{bad-json', 'failed')", [], @@ -4360,7 +4379,7 @@ mod tests { fn incomplete_reference_snapshot_preserves_every_apparent_orphan() -> Result<()> { let fixture = Fixture::new()?; fixture.write("b/orphan.webp", b"orphan")?; - fixture.post(None, None, None, "")?; + let _post_id = fixture.post(None, None, None, "")?; let limited = ReconcileLimits { database_rows_per_pass: 1, ..limits() @@ -4391,7 +4410,7 @@ mod tests { primary_thumb_path: None, primary_mime_type: None, }; - fixture.pool.get()?.execute( + let _inserted_staged_intents = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('finalize', 'upload_finalize', ?1)", [serde_json::to_string(&payload)?], @@ -4416,7 +4435,7 @@ mod tests { primary_thumb_path: None, primary_mime_type: None, }; - fixture.pool.get()?.execute( + let _inserted_installed_intents = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('installed', 'upload_finalize', ?1)", [serde_json::to_string(&installed_payload)?], @@ -4437,7 +4456,7 @@ mod tests { primary_thumb_path: None, primary_mime_type: None, }; - fixture.pool.get()?.execute( + let _inserted_lost_intents = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('lost', 'upload_finalize', ?1)", [serde_json::to_string(&lost_payload)?], @@ -4466,12 +4485,12 @@ mod tests { fn cleanup_intent_that_conflicts_with_post_is_report_only() -> Result<()> { let fixture = Fixture::new()?; fixture.write("b/active.webp", b"active")?; - fixture.post(Some("b/active.webp"), None, Some("image/webp"), "")?; + let _post_id = fixture.post(Some("b/active.webp"), None, Some("image/webp"), "")?; let payload = serde_json::to_string(&crate::pending_fs::DeleteFilesPayload { paths: vec!["b/active.webp".to_owned()], dirs: Vec::new(), })?; - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('conflict', 'delete_files', ?1)", [payload], @@ -4505,7 +4524,7 @@ mod tests { "t": "AudioWaveform", "d": {"post_id": post_id, "file_path": "b/audio.mp3", "board_short": "b"} }); - fixture.pool.get()?.execute( + let _rows_affected = fixture.pool.get()?.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('audio_waveform', ?1, 'pending')", [job.to_string()], @@ -4531,16 +4550,16 @@ mod tests { paths: vec!["b/already-gone.webp".to_owned()], dirs: Vec::new(), })?; - fixture.pool.get()?.execute( + let _inserted_completed_intents = fixture.pool.get()?.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json) VALUES ('complete', 'delete_files', ?1)", [payload], )?; let job = serde_json::json!({ "t": "VideoTranscode", - "d": {"post_id": 9999, "file_path": "b/gone.mp4", "board_short": "b"} + "d": {"post_id": 9_999_i32, "file_path": "b/gone.mp4", "board_short": "b"} }); - fixture.pool.get()?.execute( + let _inserted_completed_jobs = fixture.pool.get()?.execute( "INSERT INTO background_jobs (job_type, payload, status) VALUES ('video_transcode', ?1, 'done')", [job.to_string()], @@ -4571,7 +4590,7 @@ mod tests { fixture.intent("complete", crate::pending_fs::DELETE_FILES_KIND, &payload)?; let job = serde_json::json!({ "t": "VideoTranscode", - "d": {"post_id": 9999, "file_path": "b/gone.mp4", "board_short": "b"} + "d": {"post_id": 9_999_i32, "file_path": "b/gone.mp4", "board_short": "b"} }); fixture.job("video_transcode", &job, "done")?; fixture.hash(&digest(b"missing"), "b/missing.webp", "", "image/webp")?; diff --git a/src/media/thumbnail.rs b/src/media/thumbnail.rs index 5e62313b..0ee3d9f4 100644 --- a/src/media/thumbnail.rs +++ b/src/media/thumbnail.rs @@ -153,7 +153,7 @@ pub fn generate_thumbnail( drop(std::fs::remove_file(output_path)); drop(std::fs::remove_file(&placeholder_path)); match pdf_first_page_thumbnail(input_path, output_path, max_dim) { - Ok(PdfThumbnailOutcome::Rendered { .. }) => Ok(output_path.to_path_buf()), + Ok(PdfThumbnailOutcome::Rendered { renderer: _ }) => Ok(output_path.to_path_buf()), Ok(PdfThumbnailOutcome::Placeholder) => Ok(placeholder_path), Err(error) => { drop(std::fs::remove_file(output_path)); @@ -316,8 +316,10 @@ fn render_pdf(input_path: &Path, output_path: &Path, max_dim: u32) -> Result<()> /// Scale one dimension proportionally using widened integer arithmetic. fn scaled_dimension(side: u32, max_dimension: u32, denominator: u32) -> u32 { - let scaled = - u64::from(side).saturating_mul(u64::from(max_dimension)) / u64::from(denominator.max(1)); + let scaled = u64::from(side) + .saturating_mul(u64::from(max_dimension)) + .checked_div(u64::from(denominator.max(1))) + .unwrap_or_else(|| u64::from(max_dimension)); u32::try_from(scaled).unwrap_or(max_dimension) } @@ -461,7 +463,12 @@ mod tests { generate_thumbnail(&input, "application/pdf", &output, 100, false) }) .join() - .map_err(|_| anyhow::anyhow!("PDF preview test thread panicked"))??; + .map_err(|panic| { + anyhow::anyhow!( + "PDF preview test thread panicked: {}", + super::super::process::panic_message(panic.as_ref()) + ) + })??; anyhow::ensure!( generated.extension().is_some_and(|ext| ext == "webp"), "another thread inherited PDF failure injection" diff --git a/src/middleware/ip.rs b/src/middleware/ip.rs index 07abf8c4..fb1651b1 100644 --- a/src/middleware/ip.rs +++ b/src/middleware/ip.rs @@ -159,10 +159,10 @@ mod tests { #[test] fn client_ip_extractor_is_ready_with_or_without_peer_metadata() -> anyhow::Result<()> { let peer = SocketAddr::from(([198, 51, 100, 10], 8080)); - for (peer, expected) in [(Some(peer), "198.51.100.10"), (None, "unknown")] { + for (test_peer, expected) in [(Some(peer), "198.51.100.10"), (None, "unknown")] { let (mut parts, ()) = axum::http::Request::new(()).into_parts(); - if let Some(peer) = peer { - parts.extensions.insert(ConnectInfo(peer)); + if let Some(client_peer) = test_peer { + let _previous_peer = parts.extensions.insert(ConnectInfo(client_peer)); } let resolved = ClientIp::from_request_parts(&mut parts, &()) .now_or_never() @@ -243,19 +243,19 @@ mod tests { #[test] fn tor_stream_token_precedes_spoofed_forwarded_headers_for_loopback_peer() { let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 49_152); - crate::detect::TOR_STREAM_TOKENS.insert(peer, Arc::from("tor:test-stream")); + drop(crate::detect::TOR_STREAM_TOKENS.insert(peer, Arc::from("tor:test-stream"))); let mut headers = HeaderMap::new(); - headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.10")); - headers.insert( + drop(headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.10"))); + drop(headers.insert( "x-forwarded-for", HeaderValue::from_static("203.0.113.7, 127.0.0.1"), - ); + )); let trusted = vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()]; let resolved = resolved_client_ip(&headers, Some(peer), true, &trusted, true); - crate::detect::TOR_STREAM_TOKENS.remove(&peer); + drop(crate::detect::TOR_STREAM_TOKENS.remove(&peer)); assert_eq!(resolved, "tor:test-stream"); } @@ -263,7 +263,7 @@ mod tests { fn forwarded_headers_still_apply_for_non_tor_trusted_proxy_peer() { let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 49_153); let mut headers = HeaderMap::new(); - headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.10")); + drop(headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.10"))); let trusted = vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()]; assert_eq!( @@ -275,8 +275,8 @@ mod tests { fn untrusted_forwarded_headers_do_not_control_identity() { let peer = SocketAddr::from(([198, 51, 100, 10], 8080)); let mut headers = HeaderMap::new(); - headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.20")); - headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.21")); + drop(headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.20"))); + drop(headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.21"))); let trusted = vec!["127.0.0.1/32".to_owned()]; assert_eq!( resolved_client_ip(&headers, Some(peer), true, &trusted, false), @@ -289,21 +289,21 @@ mod tests { fn malformed_forwarded_identity_falls_back_and_ipv6_is_canonical() { let peer = SocketAddr::from(([127, 0, 0, 1], 8080)); let mut headers = HeaderMap::new(); - headers.insert("x-real-ip", HeaderValue::from_static("invented-visitor")); - headers.insert( + drop(headers.insert("x-real-ip", HeaderValue::from_static("invented-visitor"))); + drop(headers.insert( "x-forwarded-for", HeaderValue::from_static("also-invalid, 203.0.113.1"), - ); + )); let trusted = vec!["127.0.0.1/32".to_owned()]; assert_eq!( resolved_client_ip(&headers, Some(peer), true, &trusted, false), "127.0.0.1", "malformed proxy identities must not create arbitrary counters" ); - headers.insert( + drop(headers.insert( "x-real-ip", HeaderValue::from_static("2001:0db8:0:0:0:0:0:1"), - ); + )); assert_eq!( resolved_client_ip(&headers, Some(peer), true, &trusted, false), "2001:db8::1", diff --git a/src/middleware/state.rs b/src/middleware/state.rs index c809c1bd..4dc08fd5 100644 --- a/src/middleware/state.rs +++ b/src/middleware/state.rs @@ -284,7 +284,7 @@ impl DbMaintenanceJobs { DbMaintenanceJobStatus::Running { job_id: current_job_id, started_at, - .. + phase: _, } if *current_job_id == job_id => { *status = DbMaintenanceJobStatus::Running { job_id, @@ -294,9 +294,20 @@ impl DbMaintenanceJobs { true } DbMaintenanceJobStatus::Idle - | DbMaintenanceJobStatus::Running { .. } - | DbMaintenanceJobStatus::Finished { .. } - | DbMaintenanceJobStatus::Failed { .. } => false, + | DbMaintenanceJobStatus::Running { + job_id: _, + started_at: _, + phase: _, + } + | DbMaintenanceJobStatus::Finished { + job_id: _, + report: _, + } + | DbMaintenanceJobStatus::Failed { + job_id: _, + finished_at: _, + message: _, + } => false, } } @@ -307,7 +318,8 @@ impl DbMaintenanceJobs { match &*status { DbMaintenanceJobStatus::Running { job_id: current_job_id, - .. + started_at: _, + phase: _, } if *current_job_id == job_id => { *status = DbMaintenanceJobStatus::Finished { job_id, @@ -316,9 +328,20 @@ impl DbMaintenanceJobs { true } DbMaintenanceJobStatus::Idle - | DbMaintenanceJobStatus::Running { .. } - | DbMaintenanceJobStatus::Finished { .. } - | DbMaintenanceJobStatus::Failed { .. } => false, + | DbMaintenanceJobStatus::Running { + job_id: _, + started_at: _, + phase: _, + } + | DbMaintenanceJobStatus::Finished { + job_id: _, + report: _, + } + | DbMaintenanceJobStatus::Failed { + job_id: _, + finished_at: _, + message: _, + } => false, } } @@ -329,7 +352,8 @@ impl DbMaintenanceJobs { match &*status { DbMaintenanceJobStatus::Running { job_id: current_job_id, - .. + started_at: _, + phase: _, } if *current_job_id == job_id => { *status = DbMaintenanceJobStatus::Failed { job_id, @@ -339,9 +363,20 @@ impl DbMaintenanceJobs { true } DbMaintenanceJobStatus::Idle - | DbMaintenanceJobStatus::Running { .. } - | DbMaintenanceJobStatus::Finished { .. } - | DbMaintenanceJobStatus::Failed { .. } => false, + | DbMaintenanceJobStatus::Running { + job_id: _, + started_at: _, + phase: _, + } + | DbMaintenanceJobStatus::Finished { + job_id: _, + report: _, + } + | DbMaintenanceJobStatus::Failed { + job_id: _, + finished_at: _, + message: _, + } => false, } } @@ -364,9 +399,17 @@ impl DbMaintenanceJobStatus { pub const fn job_id(&self) -> Option { match self { Self::Idle => None, - Self::Running { job_id, .. } - | Self::Finished { job_id, .. } - | Self::Failed { job_id, .. } => Some(*job_id), + Self::Running { + job_id, + started_at: _, + phase: _, + } + | Self::Finished { job_id, report: _ } + | Self::Failed { + job_id, + finished_at: _, + message: _, + } => Some(*job_id), } } } @@ -391,6 +434,10 @@ impl Drop for MaintenanceGuard { clippy::struct_excessive_bools, reason = "independent runtime capability flags are shared across handlers and workers" )] +#[expect( + clippy::partial_pub_fields, + reason = "the public runtime services remain available to embedders; the internal upload gate is restricted to trusted request handling" +)] /// Shared runtime services and capability state used by request handlers. pub struct AppState { /// Admit normal HTTP work only after initialization and supervisor observation. @@ -545,7 +592,7 @@ mod tests { assert!( matches!( first_status, - DbMaintenanceJobStatus::Running { job_id, .. } if job_id == first_job_id + DbMaintenanceJobStatus::Running { job_id, started_at: _, phase: _ } if job_id == first_job_id ), "the first running state should retain its assigned identifier" ); @@ -559,7 +606,7 @@ mod tests { assert!( matches!( jobs.snapshot(), - DbMaintenanceJobStatus::Finished { job_id, .. } if job_id == first_job_id + DbMaintenanceJobStatus::Finished { job_id, report: _ } if job_id == first_job_id ), "the finished state should retain the first job identifier" ); @@ -576,7 +623,7 @@ mod tests { assert!( matches!( jobs.snapshot(), - DbMaintenanceJobStatus::Failed { job_id, .. } if job_id == second_job_id + DbMaintenanceJobStatus::Failed { job_id, finished_at: _, message: _ } if job_id == second_job_id ), "the failed state should retain the second job identifier" ); @@ -600,7 +647,7 @@ mod tests { DbMaintenanceJobStatus::Running { job_id, phase: DbMaintenanceJobPhase::Starting, - .. + started_at: _ } if job_id == second_job_id ), "a stale phase update should leave the newer job at its starting phase" @@ -616,7 +663,7 @@ mod tests { DbMaintenanceJobStatus::Running { job_id, phase: DbMaintenanceJobPhase::Repair, - .. + started_at: _ } if job_id == second_job_id ), "the matching phase update should preserve the job identity" @@ -643,7 +690,7 @@ mod tests { assert!( matches!( jobs.snapshot(), - DbMaintenanceJobStatus::Running { job_id, .. } if job_id == second_job_id + DbMaintenanceJobStatus::Running { job_id, started_at: _, phase: _ } if job_id == second_job_id ), "a stale completion should leave the newer job running" ); @@ -655,7 +702,7 @@ mod tests { assert!( matches!( jobs.snapshot(), - DbMaintenanceJobStatus::Finished { job_id, .. } if job_id == second_job_id + DbMaintenanceJobStatus::Finished { job_id, report: _ } if job_id == second_job_id ), "the matching completion should retain the active job identifier" ); @@ -676,7 +723,7 @@ mod tests { assert!( matches!( jobs.snapshot(), - DbMaintenanceJobStatus::Running { job_id, .. } if job_id == second_job_id + DbMaintenanceJobStatus::Running { job_id, started_at: _, phase: _ } if job_id == second_job_id ), "a stale failure should leave the newer job running" ); @@ -691,7 +738,7 @@ mod tests { DbMaintenanceJobStatus::Failed { job_id, message, - .. + finished_at: _ } if job_id == second_job_id && message == "current failure" ), "the matching failure should preserve its job identifier and message" @@ -714,7 +761,7 @@ mod tests { DbMaintenanceJobStatus::Running { job_id, phase: DbMaintenanceJobPhase::Starting, - .. + started_at: _ } if job_id == second_job_id ), "the newer job should remain running after a stale failure" diff --git a/src/models.rs b/src/models.rs index 82f98b86..97846321 100644 --- a/src/models.rs +++ b/src/models.rs @@ -293,7 +293,7 @@ pub enum BannerPlacement { Catalog, } -/// A board, e.g. /tech/ — Technology +/// A board, e.g. /tech/ — Technology. #[derive(Debug, Clone, Serialize, Deserialize)] // This type mirrors serialized or render state, so the boolean count is an intentional tradeoff. #[expect( @@ -472,7 +472,7 @@ pub struct Theme { pub custom_css: String, } -/// A thread (the OP post + its replies share this record for metadata) +/// A thread (the OP post + its replies share this record for metadata). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Thread { /// Database primary key and public thread number. @@ -511,7 +511,7 @@ pub struct Thread { pub op_id: Option, } -/// A single post (OP or reply) +/// A single post (OP or reply). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Post { /// Database primary key and public post number. @@ -566,7 +566,7 @@ pub struct Post { pub media_processing_error: Option, } -/// Admin user record +/// Admin user record. #[derive(Debug, Clone, Serialize)] pub struct AdminUser { /// Database primary key. @@ -579,7 +579,7 @@ pub struct AdminUser { pub created_at: i64, } -/// Active admin session +/// Active admin session. #[derive(Debug, Clone, Serialize)] pub struct AdminSession { /// Opaque session identifier stored in the authentication cookie. @@ -592,7 +592,7 @@ pub struct AdminSession { pub expires_at: i64, } -/// A banned IP hash +/// A banned IP hash. #[derive(Debug, Clone, Serialize)] pub struct Ban { /// Database primary key. @@ -607,7 +607,7 @@ pub struct Ban { pub created_at: i64, } -/// A word filter rule +/// A word filter rule. #[derive(Debug, Clone, Serialize)] pub struct WordFilter { /// Database primary key. @@ -618,7 +618,7 @@ pub struct WordFilter { pub replacement: String, } -/// Board with live thread count, used on the home page +/// Board with live thread count, used on the home page. #[derive(Debug, Clone, Serialize)] pub struct BoardStats { /// Board configuration and identity. @@ -627,18 +627,18 @@ pub struct BoardStats { pub thread_count: i64, } -/// Summary used on board index: thread + its last few reply counts +/// Summary used on board index: thread + its last few reply counts. #[derive(Debug, Clone, Serialize)] pub struct ThreadSummary { /// Thread metadata and opening-post preview fields. pub thread: Thread, - /// Latest N replies (for board index preview) + /// Latest N replies (for board index preview). pub preview_posts: Vec, - /// How many replies are hidden (total - preview shown) + /// How many replies are hidden (total - preview shown). pub omitted: i64, } -/// A poll attached to a thread's OP +/// A poll attached to a thread's OP. #[derive(Debug, Clone, Serialize)] pub struct Poll { /// Database primary key. @@ -653,7 +653,7 @@ pub struct Poll { pub created_at: i64, } -/// A single poll option with live vote count (joined from `poll_votes`) +/// A single poll option with live vote count (joined from `poll_votes`). #[derive(Debug, Clone, Serialize)] pub struct PollOption { /// Database primary key. @@ -668,7 +668,7 @@ pub struct PollOption { pub vote_count: i64, } -/// Full poll data passed to templates +/// Full poll data passed to templates. #[derive(Debug, Clone, Serialize)] pub struct PollData { /// Poll metadata. @@ -677,9 +677,9 @@ pub struct PollData { pub options: Vec, /// Sum of votes across all choices. pub total_votes: i64, - /// Which `option_id` this user voted for, if any + /// Which `option_id` this user voted for, if any. pub user_voted_option: Option, - /// true when `expires_at` <= now + /// true when `expires_at` <= now. pub is_expired: bool, } @@ -711,7 +711,7 @@ impl Default for SearchQuery { } } -/// Pagination helper +/// Pagination helper. #[derive(Debug, Clone, Serialize)] pub struct Pagination { /// Current one-based page. @@ -745,7 +745,12 @@ impl Pagination { // construction just in case. let pp = self.per_page.max(1); let t = self.total.max(0); - ((t + pp - 1) / pp).max(1) + // Computing the ceiling from quotient/remainder avoids overflowing + // when a legitimate total approaches i64::MAX. Both operands are + // nonnegative and the divisor is strictly positive. + t.div_euclid(pp) + .saturating_add(i64::from(t.rem_euclid(pp) != 0)) + .max(1) } /// Returns the zero-based record offset for the current page. @@ -773,19 +778,19 @@ impl Pagination { /// Aggregate site-wide statistics shown on the home page. #[derive(Debug, Clone, Default, Serialize)] pub struct SiteStats { - /// Total posts ever made + /// Total posts ever made. pub total_posts: i64, - /// Total image files ever uploaded + /// Total image files ever uploaded. pub total_images: i64, - /// Total video files ever uploaded + /// Total video files ever uploaded. pub total_videos: i64, - /// Total audio files ever uploaded + /// Total audio files ever uploaded. pub total_audio: i64, - /// Total bytes of currently stored files (still on disk) + /// Total bytes of currently stored files (still on disk). pub active_bytes: i64, } -/// A user-filed report against a post +/// A user-filed report against a post. #[derive(Debug, Clone, Serialize)] pub struct Report { /// Database primary key. @@ -810,21 +815,21 @@ pub struct Report { pub resolved_by: Option, } -/// Report enriched with context from joined tables (used in admin inbox) +/// Report enriched with context from joined tables (used in admin inbox). #[derive(Debug, Clone, Serialize)] pub struct ReportWithContext { /// Underlying report record. pub report: Report, /// Short name of the board containing the reported post. pub board_short: String, - /// First 120 chars of the reported post body for preview + /// First 120 chars of the reported post body for preview. pub post_preview: String, /// IP hash of the post's author (for quick ban from the inbox). /// `None` when the post has no inbound client IP. pub post_ip_hash: Option, } -/// A single entry in the moderation action log +/// A single entry in the moderation action log. #[derive(Debug, Clone, Serialize)] pub struct ModLogEntry { /// Database primary key. @@ -833,9 +838,9 @@ pub struct ModLogEntry { pub admin_id: i64, /// Administrator username captured for display. pub admin_name: String, - /// E.g. "`delete_post`", "ban", "sticky", "lock", "`resolve_report`" + /// E.g. "`delete_post`", "ban", "sticky", "lock", "`resolve_report`". pub action: String, - /// "post" | "thread" | "board" | "ban" | "report" + /// "post" | "thread" | "board" | "ban" | "report". pub target_type: String, /// Optional identifier of the affected record. pub target_id: Option, @@ -895,7 +900,7 @@ pub struct BackupInfo { pub downloadable_archive: bool, } -/// A user-submitted ban appeal +/// A user-submitted ban appeal. #[derive(Debug, Clone, Serialize)] pub struct BanAppeal { /// Database primary key. @@ -1244,23 +1249,54 @@ mod tests { #[test] fn pagination_has_prev_and_next() { - let p = Pagination::new(1, 10, 30); - assert!(!p.has_prev(), "the first page must not have a predecessor"); + let first_page = Pagination::new(1, 10, 30); + assert!( + !first_page.has_prev(), + "the first page must not have a predecessor" + ); assert!( - p.has_next(), + first_page.has_next(), "the first of three pages must have a successor" ); - let p = Pagination::new(2, 10, 30); - assert!(p.has_prev(), "the second page must have a predecessor"); + let second_page = Pagination::new(2, 10, 30); + assert!( + second_page.has_prev(), + "the second page must have a predecessor" + ); assert!( - p.has_next(), + second_page.has_next(), "the second of three pages must have a successor" ); - let p = Pagination::new(3, 10, 30); - assert!(p.has_prev(), "the third page must have a predecessor"); - assert!(!p.has_next(), "the final page must not have a successor"); + let final_page = Pagination::new(3, 10, 30); + assert!( + final_page.has_prev(), + "the third page must have a predecessor" + ); + assert!( + !final_page.has_next(), + "the final page must not have a successor" + ); + } + + #[test] + fn pagination_total_pages_handles_maximum_counts_without_overflow() { + assert_eq!( + Pagination::new(1, 1, i64::MAX).total_pages(), + i64::MAX, + "one record per page must preserve the maximum count" + ); + assert_eq!( + Pagination::new(1, 2, i64::MAX).total_pages(), + 4_611_686_018_427_387_904_i64, + "partial final pages must round up without overflowing" + ); + assert_eq!( + Pagination::new(1, i64::MAX, i64::MAX).total_pages(), + 1, + "the maximum count must fit in a maximum-size page" + ); } #[test] diff --git a/src/pending_fs.rs b/src/pending_fs.rs index dee696a1..8f435ff4 100644 --- a/src/pending_fs.rs +++ b/src/pending_fs.rs @@ -160,7 +160,7 @@ fn safe_relative_path(relative_path: &str, context: &str) -> Result { /// Validate the shape of a managed board upload path. fn validate_managed_upload_path(relative_path: &str, thumbnail: bool) -> Result { let rel = safe_relative_path(relative_path, "Upload finalize") - .map_err(|_| anyhow::anyhow!("Upload finalize artifact path contains unsafe components"))?; + .context("Upload finalize artifact path contains unsafe components")?; let components = rel .iter() .map(|component| component.to_str()) @@ -197,7 +197,7 @@ pub(crate) fn validate_upload_finalize_payload( validated_restore_path(upload_dir).context("Upload finalize managed root is invalid")?; let pending_root = upload_root.join(".pending"); let stage_dir = validated_restore_path(Path::new(&payload.stage_dir)) - .map_err(|_| anyhow::anyhow!("Upload finalize stage path is invalid"))?; + .context("Upload finalize stage path is invalid")?; if stage_dir == pending_root || !stage_dir.starts_with(&pending_root) { anyhow::bail!("Upload finalize stage is outside the managed pending root"); } @@ -228,10 +228,10 @@ pub(crate) fn validate_upload_finalize_payload( } if let Some(file_path) = payload.primary_file_path.as_deref() { - validate_managed_upload_path(file_path, false)?; + validate_managed_upload_path(file_path, false).map(|_validated_path| ())?; } if let Some(thumb_path) = payload.primary_thumb_path.as_deref() { - validate_managed_upload_path(thumb_path, true)?; + validate_managed_upload_path(thumb_path, true).map(|_validated_path| ())?; let file_path = payload.primary_file_path.as_deref().ok_or_else(|| { anyhow::anyhow!("Upload finalize thumbnail metadata has no primary file") })?; @@ -346,7 +346,8 @@ fn artifact_is_installed( anyhow::bail!("Upload finalize {category} target is not a regular file"); } crate::utils::fs_security::canonical_parent_for_new_child(upload_dir, path) - .with_context(|| format!("Validate {category} target parent"))?; + .with_context(|| format!("Validate {category} target parent")) + .map(|_validated_path| ())?; crate::utils::fs_security::assert_regular_file_no_symlink(path) .with_context(|| format!("Validate {category} target"))?; digest.map_or(Ok(true), |expected| { @@ -383,12 +384,14 @@ fn move_required_stage_file( } } else { crate::utils::fs_security::existing_regular_file_child(stage_dir, relative_path) - .context("Validate legacy staged required upload artifact")?; + .context("Validate legacy staged required upload artifact") + .map(|_validated_path| ())?; } ensure_parent_dir(&target)?; crate::utils::fs_security::canonical_parent_for_new_child(upload_dir, &target) - .context("Validate required upload target parent")?; + .context("Validate required upload target parent") + .map(|_validated_path| ())?; if artifact_is_installed(upload_dir, &target, digest, "required")? { std::fs::remove_file(&source) .with_context(|| "Remove already-finalized staged required upload artifact")?; @@ -428,12 +431,14 @@ fn move_optional_stage_file( } } else { crate::utils::fs_security::existing_regular_file_child(stage_dir, relative_path) - .context("Validate legacy staged optional upload artifact")?; + .context("Validate legacy staged optional upload artifact") + .map(|_validated_path| ())?; } ensure_parent_dir(&target)?; crate::utils::fs_security::canonical_parent_for_new_child(upload_dir, &target) - .context("Validate optional upload target parent")?; + .context("Validate optional upload target parent") + .map(|_validated_path| ())?; if artifact_is_installed(upload_dir, &target, digest, "optional")? { std::fs::remove_file(&source) .context("Remove already-finalized staged optional upload artifact")?; @@ -1166,12 +1171,14 @@ fn commit_upload_finalize_metadata( "UPDATE posts SET thumb_path = NULL WHERE thumb_path = ?1", [thumb_path], ) - .context("Clear stale post thumbnail paths")?; + .context("Clear stale post thumbnail paths") + .map(|_affected_rows| ())?; conn.execute( "UPDATE file_hashes SET thumb_path = '' WHERE thumb_path = ?1", [thumb_path], ) - .context("Clear stale deduplication thumbnail paths")?; + .context("Clear stale deduplication thumbnail paths") + .map(|_affected_rows| ())?; } if let (Some(hash), Some(file_path), Some(mime_type)) = ( @@ -1452,8 +1459,8 @@ fn referenced_banner_paths( ); continue; }; - referenced.insert(webp.clone()); - referenced.insert(webp.with_extension("gif")); + let _webp_inserted = referenced.insert(webp.clone()); + let _gif_inserted = referenced.insert(webp.with_extension("gif")); } Ok(referenced) } @@ -1616,7 +1623,7 @@ fn apply_pending_fs_op( if op.kind == UPLOAD_FINALIZE_KIND { let payload: UploadFinalizePayload = serde_json::from_str(&op.payload_json) .with_context(|| format!("Parse upload_finalize payload for {}", op.id))?; - referenced_upload_stage_dirs.insert(payload.stage_dir.clone()); + let _inserted = referenced_upload_stage_dirs.insert(payload.stage_dir.clone()); return finalize_upload_payload_with_pool(pool, upload_dir, Some(&op.id), &payload); } let conn = pool @@ -1661,7 +1668,8 @@ fn apply_pending_fs_op( .with_context(|| format!("Parse original_prune payload for {}", op.id))?; crate::media::prune::finalize_original_prune_payload( &conn, upload_dir, &op.id, &payload, - )?; + ) + .map(|_operation_summary| ())?; } other => anyhow::bail!("Unknown pending_fs_op kind {other:?} for {}", op.id), } @@ -1985,7 +1993,8 @@ mod tests { thumb_path: Option<&str>, ) -> Result { crate::db::create_board(conn, "tech", "Technology", "", false) - .context("Create upload recovery board")?; + .context("Create upload recovery board") + .map(|_created_id| ())?; let board_id: i64 = conn.query_row( "SELECT id FROM boards WHERE short_name = 'tech'", [], @@ -2016,7 +2025,7 @@ mod tests { ) -> Result { let mut artifact_sha256 = std::collections::BTreeMap::new(); for path in required.iter().copied().chain(optional) { - artifact_sha256.insert( + let _previous_value = artifact_sha256.insert( path.to_owned(), super::upload_artifact_sha256(&stage_dir.join(path), "test")?, ); @@ -2066,8 +2075,8 @@ mod tests { upload_dir.to_str().context("UTF-8 upload root")?, )?; - let conn = pool.get()?; - let state: (Option, i64, String) = conn.query_row( + let recovered_conn = pool.get()?; + let state: (Option, i64, String) = recovered_conn.query_row( "SELECT p.thumb_path, (SELECT COUNT(*) FROM pending_fs_ops), (SELECT thumb_path FROM file_hashes WHERE sha256 = 'no-thumb-hash') @@ -2131,7 +2140,8 @@ mod tests { serde_json::to_string(&first_payload)?, serde_json::to_string(&later_payload)? ], - )?; + ) + .map(|_affected_rows| ())?; drop(conn); crate::pending_fs::reconcile_pending_fs_ops( @@ -2143,20 +2153,20 @@ mod tests { upload_dir.to_str().context("UTF-8 upload root")?, )?; - let conn = pool.get()?; - let thumb_path: Option = conn.query_row( + let recovered_conn = pool.get()?; + let thumb_path: Option = recovered_conn.query_row( "SELECT thumb_path FROM posts WHERE id = ?1", [post_id], |row| row.get(0), )?; - let hash_thumb: String = conn.query_row( + let hash_thumb: String = recovered_conn.query_row( "SELECT thumb_path FROM file_hashes WHERE sha256 = 'lost-thumb-hash'", [], |row| row.get(0), )?; assert!(thumb_path.is_none()); assert_eq!(hash_thumb, ""); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + assert!(crate::db::list_pending_fs_ops(&recovered_conn)?.is_empty()); assert!(upload_dir.join("tech/original.png").is_file()); assert!(upload_dir.join("tech/later.png").is_file()); Ok(()) @@ -2223,8 +2233,8 @@ mod tests { first.is_err(), "injected metadata repair should fail closed" ); - let conn = pool.get()?; - let state: (Option, String, i64) = conn.query_row( + let retry_conn = pool.get()?; + let state: (Option, String, i64) = retry_conn.query_row( "SELECT p.thumb_path, (SELECT thumb_path FROM file_hashes WHERE sha256 = 'repair-retry-hash'), @@ -2242,15 +2252,15 @@ mod tests { ) ); assert!(upload_dir.join("tech/original.png").is_file()); - conn.execute_batch("DROP TRIGGER fail_thumbnail_hash_repair")?; - drop(conn); + retry_conn.execute_batch("DROP TRIGGER fail_thumbnail_hash_repair")?; + drop(retry_conn); crate::pending_fs::reconcile_pending_fs_ops( &pool, upload_dir.to_str().context("UTF-8 upload root")?, )?; - let conn = pool.get()?; - let final_state: (Option, String, i64) = conn.query_row( + let recovered_conn = pool.get()?; + let final_state: (Option, String, i64) = recovered_conn.query_row( "SELECT p.thumb_path, (SELECT thumb_path FROM file_hashes WHERE sha256 = 'repair-retry-hash'), @@ -2314,29 +2324,29 @@ mod tests { )?; drop(conn); - for _ in 0..2 { + for _ in 0_i32..2_i32 { crate::pending_fs::reconcile_pending_fs_ops( &pool, upload_dir.to_str().context("UTF-8 upload root")?, )?; } - let conn = pool.get()?; - let thumb_path: Option = conn.query_row( + let recovered_conn = pool.get()?; + let thumb_path: Option = recovered_conn.query_row( "SELECT thumb_path FROM posts WHERE id = ?1", [post_id], |row| row.get(0), )?; assert!(thumb_path.is_none()); assert_eq!( - conn.query_row( + recovered_conn.query_row( "SELECT thumb_path FROM file_hashes WHERE sha256 = 'legacy-hash'", [], |row| row.get::<_, String>(0) )?, "" ); - assert!(crate::db::list_pending_fs_ops(&conn)?.is_empty()); + assert!(crate::db::list_pending_fs_ops(&recovered_conn)?.is_empty()); assert_eq!( std::fs::read(upload_dir.join("tech/original.png"))?, b"original" @@ -2372,7 +2382,7 @@ mod tests { let valid_payload = upload_payload(&valid_stage, &["tech/valid.png"], None, "valid-hash")?; let pool = init_test_pool()?; let conn = pool.get()?; - seed_upload_post(&conn, "tech/missing.png", None)?; + seed_upload_post(&conn, "tech/missing.png", None).map(|_created_id| ())?; conn.execute( "INSERT INTO pending_fs_ops (id, kind, payload_json, created_at) VALUES ('missing-required', 'upload_finalize', ?1, 1), @@ -2381,7 +2391,8 @@ mod tests { serde_json::to_string(&missing_payload)?, serde_json::to_string(&valid_payload)? ], - )?; + ) + .map(|_affected_rows| ())?; drop(conn); let result = crate::pending_fs::reconcile_pending_fs_ops( @@ -2392,8 +2403,8 @@ mod tests { result.is_err(), "missing required original must remain fail-closed" ); - let conn = pool.get()?; - let pending = crate::db::list_pending_fs_ops(&conn)?; + let recovered_conn = pool.get()?; + let pending = crate::db::list_pending_fs_ops(&recovered_conn)?; assert_eq!(pending.len(), 1); assert_eq!( pending.first().map(|operation| operation.id.as_str()), @@ -3076,22 +3087,23 @@ mod tests { "validation failure must preserve the staged upload tree" ); - let mut payload = full_restore_payload_for_live(&upload_live)?; - let mut malicious_swap = tor_swap_for_live(&tor_live)?; - malicious_swap.previous = outside + let mut validated_payload = full_restore_payload_for_live(&upload_live)?; + let mut outside_swap = tor_swap_for_live(&tor_live)?; + outside_swap.previous = outside .join(".keys.restore-old.0123456789abcdef0123456789abcdef") .display() .to_string(); - payload.additional_swaps.push(malicious_swap); - let error = finalize_full_restore_payload(&payload, &upload_live, Some(&tor_live)) - .err() - .context("Outside previous path was unexpectedly accepted")?; + validated_payload.additional_swaps.push(outside_swap); + let rejection_error = + finalize_full_restore_payload(&validated_payload, &upload_live, Some(&tor_live)) + .err() + .context("Outside previous path was unexpectedly accepted")?; assert!( - error + rejection_error .to_string() .contains("outside the expected restore backup area"), - "rejection should identify the backup-area constraint: {error}" + "rejection should identify the backup-area constraint: {rejection_error}" ); Ok(()) } @@ -3139,9 +3151,9 @@ mod tests { !error.to_string().is_empty(), "reconciliation rejection should include an error message" ); - let conn = pool.get().context("Get test database connection")?; + let recovered_conn = pool.get().context("Get test database connection")?; assert_eq!( - conn.query_row("SELECT COUNT(*) FROM pending_fs_ops", [], |row| row + recovered_conn.query_row("SELECT COUNT(*) FROM pending_fs_ops", [], |row| row .get::<_, i64>(0))?, 1, "failed reconciliation should retain the pending operation" @@ -3179,10 +3191,10 @@ mod tests { "live": payload.live, "previous": payload.previous, }); - let payload: FullRestoreSwapPayload = + let validated_payload: FullRestoreSwapPayload = serde_json::from_value(legacy_json).context("Deserialize legacy restore payload")?; - finalize_full_restore_payload(&payload, &upload_live, None) + finalize_full_restore_payload(&validated_payload, &upload_live, None) .context("Finalize legacy full-restore payload")?; assert_eq!( @@ -3287,9 +3299,9 @@ mod tests { "new-banner", "banner swap should publish staged content" ); - let conn = pool.get().context("Get test database connection")?; + let recovered_conn = pool.get().context("Get test database connection")?; assert!( - crate::db::list_pending_fs_ops(&conn) + crate::db::list_pending_fs_ops(&recovered_conn) .context("List pending operations")? .is_empty(), "successful recovery should clear every pending operation" diff --git a/src/restart.rs b/src/restart.rs index fdd36bb0..eaf599f5 100644 --- a/src/restart.rs +++ b/src/restart.rs @@ -1,5 +1,6 @@ //! Fixed settings restart coordination. The web process never launches a replacement. +use anyhow::Context as _; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -58,11 +59,11 @@ impl Store { crate::utils::fs_security::reject_symlink_components(path)?; crate::utils::fs_security::assert_regular_file_no_symlink(path)?; let mut options = fs::File::options(); - options.read(true); + let _read_options = options.read(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; - options.custom_flags(i32::try_from( + let _platform_options = options.custom_flags(i32::try_from( (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits(), )?); } @@ -78,7 +79,13 @@ impl Store { ); } let mut bytes = Vec::new(); - file.take(limit + 1).read_to_end(&mut bytes)?; + file.take( + limit + .checked_add(1) + .context("restart byte limit overflow")?, + ) + .read_to_end(&mut bytes) + .map(|_bytes_read| ())?; anyhow::ensure!( u64::try_from(bytes.len())? <= limit, "restart payload exceeds limit" @@ -200,9 +207,10 @@ pub fn prepare_startup() -> anyhow::Result<()> { } if settings.try_exists()? { let bytes = Store::read(&settings, MAX_SETTINGS)?; - STARTUP_SETTINGS - .set(bytes) - .map_err(|_| anyhow::anyhow!("startup settings already captured"))?; + STARTUP_SETTINGS.set(bytes).map_err(|rejected_settings| { + drop(rejected_settings); // Never expose settings secrets in diagnostics. + anyhow::anyhow!("startup settings already captured") + })?; } Ok(()) } @@ -253,7 +261,16 @@ fn prepare_container_trial( )?; anyhow::bail!("configuration recovery requires operator intervention") } - _ => Ok(()), + Phase::Idle + | Phase::Downloading + | Phase::Verifying + | Phase::BackingUp + | Phase::Activating + | Phase::Staged + | Phase::ResumingPrevious + | Phase::Succeeded + | Phase::RolledBack + | Phase::Failed => Ok(()), } } @@ -328,7 +345,9 @@ async fn verify_container_health() -> anyhow::Result<()> { .timeout(std::time::Duration::from_secs(2)) .build()?; let port = crate::config::CONFIG.port; - let deadline = tokio::time::Instant::now() + STARTUP_TIMEOUT; + let deadline = tokio::time::Instant::now() + .checked_add(STARTUP_TIMEOUT) + .context("startup deadline overflow")?; while tokio::time::Instant::now() < deadline { let checked = async { let mut response = client @@ -406,7 +425,8 @@ pub fn request_container(administrator: i64) -> anyhow::Result<()> { "candidate.sha256", configuration_digest(&candidate).as_bytes(), )?; - Store::read(&store.directory.join("known-good.toml"), MAX_SETTINGS)?; + Store::read(&store.directory.join("known-good.toml"), MAX_SETTINGS) + .map(|_operation_summary| ())?; status.operation = crate::updates::Operation::SettingsRestart; status.job = Some(Uuid::new_v4().to_string()); status.administrator = Some(administrator); @@ -439,6 +459,28 @@ pub struct View { mod tests { use super::*; + /// Reject impossible over-read limits without panicking on addition. + #[test] + fn bounded_restart_read_rejects_limit_overflow() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let payload = temporary.path().canonicalize()?.join("payload"); + fs::write(&payload, b"bounded content")?; + let overflow_error = Store::read(&payload, u64::MAX) + .err() + .context("overflowed read limit was accepted")?; + anyhow::ensure!( + overflow_error + .to_string() + .contains("restart byte limit overflow"), + "read limit must be rejected by the checked byte bound: {overflow_error:#}" + ); + anyhow::ensure!( + Store::read(&payload, 15)? == b"bounded content", + "valid bounded reads must preserve bytes" + ); + Ok(()) + } + /// Build private payloads for one disposable configuration restart. fn fixture() -> anyhow::Result<(tempfile::TempDir, Store, crate::updates::Status)> { let temp = tempfile::tempdir()?; diff --git a/src/server/cli.rs b/src/server/cli.rs index 3cd0e14c..0dfa5e9f 100644 --- a/src/server/cli.rs +++ b/src/server/cli.rs @@ -15,11 +15,11 @@ use clap::{Parser, Subcommand}; )] /// Top-level command-line arguments. pub struct Cli { - /// Absolute directory for config, database, uploads, logs, and backups + /// Absolute directory for config, database, uploads, logs, and backups. #[arg(long, global = true, value_name = "PATH")] pub data_dir: Option, - /// TCP port to bind the main forum server + /// TCP port to bind the main forum server. #[arg(long, short = 'p', global = true)] pub port: Option, @@ -72,16 +72,16 @@ pub enum AdminAction { #[arg(long)] /// Whether the board contains not-safe-for-work material. nsfw: bool, - /// Disable image uploads on this board (default: images allowed) + /// Disable image uploads on this board (default: images allowed). #[arg(long = "no-images")] no_images: bool, - /// Disable video uploads on this board (default: video allowed) + /// Disable video uploads on this board (default: video allowed). #[arg(long = "no-videos")] no_videos: bool, - /// Enable audio uploads on this board (default: audio disabled) + /// Enable audio uploads on this board (default: audio disabled). #[arg(long = "audio", conflicts_with = "no_audio")] audio: bool, - /// Compatibility flag; audio uploads are already disabled by default + /// Compatibility flag; audio uploads are already disabled by default. #[arg(long = "no-audio")] no_audio: bool, }, @@ -227,7 +227,8 @@ pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { } => { crypto::validate_password(&new_password)?; db::get_admin_by_username(&conn, &username)? - .ok_or_else(|| anyhow::anyhow!("Admin '{username}' not found."))?; + .ok_or_else(|| anyhow::anyhow!("Admin '{username}' not found.")) + .map(|_completed_value| ())?; let hash = crypto::hash_password(&new_password)?; db::update_admin_password(&conn, &username, &hash)?; writeln!( @@ -313,12 +314,14 @@ pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { stdout.flush()?; } let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; + std::io::stdin() + .read_line(&mut input) + .map(|_completed_value| ())?; if input.trim() != "yes" { writeln!(std::io::stdout().lock(), "Aborted.")?; return Ok(()); } - db::delete_board(&conn, board.id)?; + db::delete_board(&conn, board.id).map(|_completed_value| ())?; writeln!(std::io::stdout().lock(), "✓ Board /{short}/ deleted.")?; } AdminAction::ListBoards => { @@ -354,9 +357,11 @@ pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { reason, hours, } => { - let expires = hours - .filter(|&h| h > 0) - .map(|h| chrono::Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + let expires = hours.filter(|&h| h > 0).map(|h| { + chrono::Utc::now() + .timestamp() + .saturating_add(h.min(87_600).saturating_mul(3600)) + }); let id = db::add_ban(&conn, &ip_hash, &reason, expires)?; let exp_str = expires .and_then(|ts| chrono::Local.timestamp_opt(ts, 0).single()) @@ -478,9 +483,17 @@ mod tests { ]); let Some(Command::Admin { - action: AdminAction::CreateBoard { - audio, no_audio, .. - }, + action: + AdminAction::CreateBoard { + audio, + no_audio, + short: _, + name: _, + description: _, + nsfw: _, + no_images: _, + no_videos: _, + }, }) = cli.command else { anyhow::bail!("arguments should parse as create-board"); @@ -507,7 +520,17 @@ mod tests { ]); let Some(Command::Admin { - action: AdminAction::CreateBoard { audio, .. }, + action: + AdminAction::CreateBoard { + audio, + short: _, + name: _, + description: _, + nsfw: _, + no_images: _, + no_videos: _, + no_audio: _, + }, }) = cli.command else { anyhow::bail!("arguments should parse as create-board"); diff --git a/src/server/console/browse.rs b/src/server/console/browse.rs index 828fd617..a87ac330 100644 --- a/src/server/console/browse.rs +++ b/src/server/console/browse.rs @@ -37,7 +37,7 @@ impl ViewFilter { ); } KeyEvent::Backspace => { - self.query.pop(); + let _removed_character = self.query.pop(); } KeyEvent::ClearLine => self.query.clear(), KeyEvent::Enter => self.editing = false, @@ -45,7 +45,22 @@ impl ViewFilter { self.query.clear(); self.editing = false; } - _ => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Delete + | KeyEvent::Up + | KeyEvent::Down + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::PageUp + | KeyEvent::PageDown + | KeyEvent::Home + | KeyEvent::End + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} } } @@ -183,16 +198,16 @@ mod tests { vec![("aa".to_owned(), 0, 5), ("ab".to_owned(), 0, 5)], "search and sorting must compose" ); - let filter = ViewFilter { + let severity_filter = ViewFilter { mode: 2, ..ViewFilter::default() }; assert!( - filter.log_matches("2026 ERROR workers failure"), + severity_filter.log_matches("2026 ERROR workers failure"), "error filter must include errors" ); assert!( - !filter.log_matches("2026 INFO workers ERROR count=0"), + !severity_filter.log_matches("2026 INFO workers ERROR count=0"), "message content is not the log level" ); } diff --git a/src/server/console/content.rs b/src/server/console/content.rs index 6c1be0ac..3785685d 100644 --- a/src/server/console/content.rs +++ b/src/server/console/content.rs @@ -72,7 +72,7 @@ pub(super) fn render_boards( if area.width >= 78 { let columns = Layout::horizontal([Constraint::Percentage(64), Constraint::Percentage(36)]) - .spacing(1) + .spacing(1_i32) .split(area); render_board_table(frame, columns.first().copied().unwrap_or(area), app, &rows); render_board_detail(frame, columns.get(1).copied().unwrap_or(area), app, &rows); diff --git a/src/server/console/dashboard.rs b/src/server/console/dashboard.rs index 52174d18..d5adcbae 100644 --- a/src/server/console/dashboard.rs +++ b/src/server/console/dashboard.rs @@ -245,16 +245,18 @@ fn render_dashboard(frame: &mut Frame<'_>, area: Rect, stats: &ChanStats, offset let panels_height = if wide { service_height.max(operations_height) } else { - service_height + operations_height + 1 + service_height + .saturating_add(operations_height) + .saturating_add(1) }; - let content_height = panels_height + 8; + let content_height = panels_height.saturating_add(8); render_scrollable(frame, area, content_height, offset, |buffer, content| { let rows = Layout::vertical([ Constraint::Length(2), Constraint::Length(panels_height), Constraint::Length(5), ]) - .spacing(1) + .spacing(1_i32) .split(content); let attention = if !stats.is_ready { "[LOADING] Collecting server state…".to_owned() @@ -298,7 +300,7 @@ fn render_dashboard(frame: &mut Frame<'_>, area: Rect, stats: &ChanStats, offset if wide { let columns = Layout::horizontal([Constraint::Percentage(51), Constraint::Percentage(49)]) - .spacing(1) + .spacing(1_i32) .split(body); render_operations_panel(buffer, columns.first().copied().unwrap_or(body), stats); render_service_panel(buffer, columns.get(1).copied().unwrap_or(body), stats); @@ -307,7 +309,7 @@ fn render_dashboard(frame: &mut Frame<'_>, area: Rect, stats: &ChanStats, offset Constraint::Length(operations_height), Constraint::Length(service_height), ]) - .spacing(1) + .spacing(1_i32) .split(body); render_operations_panel(buffer, sections.first().copied().unwrap_or(body), stats); render_service_panel(buffer, sections.get(1).copied().unwrap_or(body), stats); @@ -456,7 +458,7 @@ fn render_operations_panel(buffer: &mut Buffer, area: Rect, stats: &ChanStats) { /// Build authoritative overview labels independently of panel geometry. fn operations_rows(stats: &ChanStats) -> Vec<(Line<'static>, Line<'static>)> { - let rate_style = if stats.rps >= 1.0 { + let rate_style = if stats.rps >= 1.0_f64 { Style::default().fg(SUCCESS) } else { Style::default().fg(MUTED) @@ -556,7 +558,7 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &ConsoleState) { let hints = footer_hints(app, area.width); let mut lines = Vec::new(); let mut spans = Vec::new(); - let mut columns = 0; + let mut columns = 0_usize; for (key, label) in hints { let pair = [ Span::styled( @@ -569,16 +571,17 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &ConsoleState) { Span::styled(format!(" {label}"), Style::default().fg(MUTED)), ]; let width: usize = pair.iter().map(Span::width).sum(); - if columns > 0 && columns + 3 + width > usize::from(area.width) { + if columns > 0 && columns.saturating_add(3).saturating_add(width) > usize::from(area.width) + { lines.push(Line::from(std::mem::take(&mut spans))); columns = 0; } if columns > 0 { spans.push(Span::raw(" ")); - columns += 3; + columns = columns.saturating_add(3); } spans.extend(pair); - columns += width; + columns = columns.saturating_add(width); } lines.push(Line::from(spans)); Paragraph::new(lines) @@ -613,7 +616,7 @@ const fn footer_hints(app: &ConsoleState, width: u16) -> &'static [(&'static str Screen::Boards => app.board_filter.editing, Screen::Tasks => app.task_filter.editing, Screen::Logs => app.log_filter.editing, - _ => false, + Screen::Dashboard | Screen::System | Screen::Configuration | Screen::Help => false, }; if editing { &[ @@ -703,7 +706,7 @@ fn render_to_buffer( let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))?; let mut app = app.clone(); - terminal.draw(|frame| render(frame, &mut app, metrics, logs))?; + let _completed_frame = terminal.draw(|frame| render(frame, &mut app, metrics, logs))?; Ok(terminal.backend().buffer().clone()) } diff --git a/src/server/console/dashboard_tests.rs b/src/server/console/dashboard_tests.rs index 62d16406..18f5dc2c 100644 --- a/src/server/console/dashboard_tests.rs +++ b/src/server/console/dashboard_tests.rs @@ -221,7 +221,7 @@ fn every_screen_and_dialog_survives_resizing_and_masks_secrets() -> anyhow::Resu (120, 40), ] { terminal.backend_mut().resize(width, height); - terminal.draw(|frame| { + let _completed_frame = terminal.draw(|frame| { render( frame, &mut app, @@ -300,7 +300,7 @@ fn form_scroll_uses_only_the_focused_field_and_whole_wide_glyphs() -> anyhow::Re field.value = FieldValue::Text("界界界界x".to_owned()); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(28, 3))?; let mut cursor = None; - terminal.draw(|frame| { + let _completed_frame = terminal.draw(|frame| { cursor = render_form_field(frame, Rect::new(0, 0, 28, 1), field, true, 5); })?; let cursor = cursor.ok_or_else(|| anyhow::anyhow!("missing focused cursor"))?; @@ -314,8 +314,8 @@ fn form_scroll_uses_only_the_focused_field_and_whole_wide_glyphs() -> anyhow::Re "cursor must follow the last glyph after horizontal scrolling" ); field.value = FieldValue::Text("abcdef".to_owned()); - terminal.draw(|frame| { - render_form_field(frame, Rect::new(0, 0, 25, 1), field, false, 80); + let _unfocused_frame = terminal.draw(|frame| { + let _unfocused_cursor = render_form_field(frame, Rect::new(0, 0, 25, 1), field, false, 80); })?; assert!( buffer_text(terminal.backend().buffer()).contains("abcd"), @@ -338,11 +338,14 @@ fn log_scroll_clamps_to_retained_content() -> anyhow::Result<()> { app.logs.rows_from_bottom = usize::MAX; app.logs.horizontal_offset = u16::MAX; let logs = LogSnapshot { - lines: (0..20).map(|index| format!("entry-{index:02}")).collect(), + lines: (0_i32..20_i32) + .map(|index| format!("entry-{index:02}")) + .collect(), ..LogSnapshot::default() }; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24))?; - terminal.draw(|frame| render(frame, &mut app, &ChanStats::default(), &logs))?; + let _completed_frame = + terminal.draw(|frame| render(frame, &mut app, &ChanStats::default(), &logs))?; assert_eq!( app.logs.rows_from_bottom, logs.lines.len() - app.logs.visible_rows, @@ -356,8 +359,13 @@ fn log_scroll_clamps_to_retained_content() -> anyhow::Result<()> { buffer_text(terminal.backend().buffer()).contains("entry-00"), "oldest retained content must remain visible" ); - app.handle_key(&super::super::input::KeyEvent::Down, 0, (80, 24)); - terminal.draw(|frame| render(frame, &mut app, &ChanStats::default(), &logs))?; + anyhow::ensure!( + app.handle_key(&super::super::input::KeyEvent::Down, 0, (80, 24)) + == super::super::state::ConsoleAction::None, + "navigation and editing must not request server-side work" + ); + let _scrolled_frame = + terminal.draw(|frame| render(frame, &mut app, &ChanStats::default(), &logs))?; assert!( buffer_text(terminal.backend().buffer()).contains("entry-01"), "one Down press must move after excessive upward scrolling" @@ -417,7 +425,11 @@ fn narrow_form_preserves_the_full_validation_rule_and_actions() -> anyhow::Resul ))), ..ConsoleState::default() }; - app.handle_key(&super::super::input::KeyEvent::Submit, 0, (44, 14)); + anyhow::ensure!( + app.handle_key(&super::super::input::KeyEvent::Submit, 0, (44, 14)) + == super::super::state::ConsoleAction::None, + "navigation and editing must not request server-side work" + ); let buffer = render_to_buffer( Rect::new(0, 0, 44, 14), &app, @@ -447,7 +459,7 @@ fn populated_tasks_empty_filters_errors_and_details_render_at_all_sizes() -> any failed: 1, recent_completed: 3, }); - for index in 0..100 { + for index in 0_i32..100_i32 { stats.operator.tasks.push(super::super::telemetry::TaskRow { id: format!("job:{index}"), kind: "Video transcode".to_owned(), @@ -475,38 +487,42 @@ fn populated_tasks_empty_filters_errors_and_details_render_at_all_sizes() -> any buffer_text(&buffer).contains("RUNNING"), "populated task state disappeared at {width}x{height}" ); - app.handle_key(&super::super::input::KeyEvent::Enter, 0, (width, height)); - let buffer = render_to_buffer( + anyhow::ensure!( + app.handle_key(&super::super::input::KeyEvent::Enter, 0, (width, height)) + == super::super::state::ConsoleAction::None, + "navigation and editing must not request server-side work" + ); + let detail_buffer = render_to_buffer( Rect::new(0, 0, width, height), &app, &stats, &LogSnapshot::default(), )?; anyhow::ensure!( - buffer_text(&buffer).contains("State: RUNNING"), + buffer_text(&detail_buffer).contains("State: RUNNING"), "task detail did not render state" ); app.dialog = None; app.task_filter.query = "no-match".to_owned(); - let buffer = render_to_buffer( + let empty_buffer = render_to_buffer( Rect::new(0, 0, width, height), &app, &stats, &LogSnapshot::default(), )?; anyhow::ensure!( - buffer_text(&buffer).contains("NO TASKS"), + buffer_text(&empty_buffer).contains("NO TASKS"), "empty filter must be explained" ); stats.operator.error = Some("Reading tasks failed; R retries".to_owned()); - let buffer = render_to_buffer( + let failure_buffer = render_to_buffer( Rect::new(0, 0, width, height), &app, &stats, &LogSnapshot::default(), )?; anyhow::ensure!( - buffer_text(&buffer).contains("UNAVAILABLE"), + buffer_text(&failure_buffer).contains("UNAVAILABLE"), "failed data must be explicit" ); stats.operator.error = None; diff --git a/src/server/console/dialogs.rs b/src/server/console/dialogs.rs index 11d7e0a3..dd08b9e9 100644 --- a/src/server/console/dialogs.rs +++ b/src/server/console/dialogs.rs @@ -62,7 +62,7 @@ fn dim_area(buffer: &mut Buffer, area: Rect) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { if let Some(cell) = buffer.cell_mut((x, y)) { - cell.set_style(cell.style().add_modifier(Modifier::DIM)); + let _styled_cell = cell.set_style(cell.style().add_modifier(Modifier::DIM)); } } } @@ -124,7 +124,7 @@ fn render_form_dialog(frame: &mut Frame<'_>, area: Rect, form: &FormState) { let expanded_error = form.error.is_some() && area.width < 64; let desired_height = u16::try_from(form.fields.len()) .unwrap_or(u16::MAX) - .saturating_add(9 + u16::from(expanded_error)) + .saturating_add(9_u16.saturating_add(u16::from(expanded_error))) .min(area.height.saturating_sub(2)); let popup = centered_rect( area, @@ -285,7 +285,8 @@ pub(super) fn render_form_field( let mut visible = String::new(); for grapheme in line.styled_graphemes(Style::default()) { if horizontal_scroll < desired_scroll { - horizontal_scroll += Span::raw(grapheme.symbol).width(); + horizontal_scroll = + horizontal_scroll.saturating_add(Span::raw(grapheme.symbol).width()); } else { visible.push_str(grapheme.symbol); } diff --git a/src/server/console/forms.rs b/src/server/console/forms.rs index 1a86388b..7eb391ea 100644 --- a/src/server/console/forms.rs +++ b/src/server/console/forms.rs @@ -77,6 +77,10 @@ pub enum FieldValue { /// One reusable form field. #[derive(Clone, PartialEq, Eq)] +#[expect( + clippy::partial_pub_fields, + reason = "preserve the existing editable field API while keeping the validated text limit private so callers cannot relax it" +)] pub struct FormField { /// Stable field identifier. pub id: FormFieldId, @@ -308,7 +312,10 @@ impl FormState { .checked_sub(1) .unwrap_or_else(|| count.saturating_sub(1)) } else { - self.focused.saturating_add(1) % count + self.focused + .saturating_add(1) + .checked_rem(count) + .unwrap_or(0) }; self.cursor = self .focused_field() @@ -359,7 +366,7 @@ impl FormState { for character in content .chars() .filter(|character| !character.is_control()) - .take(remaining + 1) + .take(remaining.saturating_add(1)) { self.insert_char(character); } @@ -380,7 +387,7 @@ impl FormState { let Some((byte_index, _)) = value.char_indices().nth(target) else { return; }; - value.remove(byte_index); + let _removed_character = value.remove(byte_index); self.cursor = target; self.error = None; } @@ -396,7 +403,7 @@ impl FormState { let Some((byte_index, _)) = value.char_indices().nth(self.cursor) else { return; }; - value.remove(byte_index); + let _removed_character = value.remove(byte_index); self.error = None; } @@ -499,9 +506,10 @@ impl FormState { } FormKind::DeleteThread => { let raw = self.text(FormFieldId::ThreadId)?.trim(); - let thread_id = raw - .parse::() - .map_err(|_| "Thread ID must be a positive whole number.".to_owned())?; + let thread_id = raw.parse::().map_err(|error| { + tracing::debug!(%error, "Console thread ID was not a valid integer"); + "Thread ID must be a positive whole number.".to_owned() + })?; if thread_id <= 0 { return Err("Thread ID must be a positive whole number.".to_owned()); } diff --git a/src/server/console/help.rs b/src/server/console/help.rs index a847cce3..e795946e 100644 --- a/src/server/console/help.rs +++ b/src/server/console/help.rs @@ -51,19 +51,21 @@ pub(super) fn render_help(frame: &mut Frame<'_>, area: Rect, offset: &mut u16) { let minimum_height = if wide { navigation_height.max(editing_height) } else { - navigation_height + editing_height + 1 + navigation_height + .saturating_add(editing_height) + .saturating_add(1) }; render_scrollable(frame, area, minimum_height, offset, |buffer, area| { let sections = if wide { Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .spacing(1) + .spacing(1_i32) .split(area) } else { Layout::vertical([ Constraint::Length(navigation_height), Constraint::Min(editing_height), ]) - .spacing(1) + .spacing(1_i32) .split(area) }; render_help_panel( @@ -87,7 +89,7 @@ fn help_description(description: &str, panel_width: u16) -> Text<'static> { let mut lines = Vec::new(); let mut line = String::new(); for word in description.split_whitespace() { - if !line.is_empty() && line.len() + 1 + word.len() > width { + if !line.is_empty() && line.len().saturating_add(1).saturating_add(word.len()) > width { lines.push(Line::from(std::mem::take(&mut line))); } if !line.is_empty() { diff --git a/src/server/console/history.rs b/src/server/console/history.rs index 5846b56d..3ae283b4 100644 --- a/src/server/console/history.rs +++ b/src/server/console/history.rs @@ -29,6 +29,10 @@ impl TrafficSample { /// Fixed-capacity history collected only while the interactive console runs. #[derive(Clone, Debug, Default)] +#[expect( + clippy::partial_pub_fields, + reason = "preserve the existing sample snapshot API while keeping the observation baseline private so rate collection retains monotonic interval bookkeeping" +)] pub struct TrafficHistory { /// Oldest-to-newest measured intervals. pub samples: VecDeque, @@ -48,7 +52,7 @@ impl TrafficHistory { return; } if self.samples.len() == HISTORY_CAPACITY { - self.samples.pop_front(); + let _evicted_sample = self.samples.pop_front(); } self.samples.push_back(TrafficSample { requests: requests.saturating_sub(previous_count), @@ -104,7 +108,7 @@ mod tests { ); assert_eq!( history.rates(), - (2.0, 2.0), + (2.0_f64, 2.0_f64), "rates must use measured elapsed time" ); history.observe(start + Duration::from_secs(2010), 0); diff --git a/src/server/console/input.rs b/src/server/console/input.rs index c7e30d2d..8fa32d67 100644 --- a/src/server/console/input.rs +++ b/src/server/console/input.rs @@ -60,7 +60,32 @@ const fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Option { KeyCode::Char('c' | 'C') => Some(KeyEvent::ForceQuit), KeyCode::Char('u' | 'U') => Some(KeyEvent::ClearLine), KeyCode::Enter => Some(KeyEvent::Submit), - _ => None, + KeyCode::Backspace + | KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Home + | KeyCode::End + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Tab + | KeyCode::BackTab + | KeyCode::Delete + | KeyCode::Insert + | KeyCode::F(_) + | KeyCode::Char(_) + | KeyCode::Null + | KeyCode::Esc + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => None, }; } if modifiers.contains(KeyModifiers::ALT) { @@ -83,7 +108,18 @@ const fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Option { KeyCode::End => Some(KeyEvent::End), KeyCode::Esc => Some(KeyEvent::Escape), KeyCode::F(2) => Some(KeyEvent::Submit), - _ => None, + KeyCode::Insert + | KeyCode::F(_) + | KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => None, } } @@ -129,7 +165,7 @@ fn map_event(event: Event) -> Option { /// /// Returns an error if the operating system refuses to create the thread. pub fn spawn(tx: mpsc::Sender, redraw: Arc) -> std::io::Result<()> { - std::thread::Builder::new() + let input_thread = std::thread::Builder::new() .name("console-input".into()) .spawn(move || loop { if tx.is_closed() || !super::is_active() { @@ -165,6 +201,8 @@ pub fn spawn(tx: mpsc::Sender, redraw: Arc) -> std::io::Result } } })?; + // Dropping the handle detaches the reader; channel closure stops its loop. + drop(input_thread); Ok(()) } diff --git a/src/server/console/mod.rs b/src/server/console/mod.rs index 2d727a81..cf1f191e 100644 --- a/src/server/console/mod.rs +++ b/src/server/console/mod.rs @@ -161,7 +161,7 @@ pub fn start( let metrics_reader = Arc::clone(shared_metrics); let app_writer = Arc::clone(shared_app); let redraw_for_render = Arc::clone(&redraw); - tokio::spawn(async move { + let render_task = tokio::spawn(async move { let mut spinner_tick = 0u8; let mut logs = dashboard::LogSnapshot::default(); let mut last_log_refresh = None; @@ -204,10 +204,10 @@ pub fn start( let mut app = app_writer.write().await; app.expire_notice(Instant::now()); - animated = matches!(app.dialog, Some(state::Dialog::Progress { .. })) + animated = matches!(app.dialog, Some(state::Dialog::Progress { label: _ })) || snapshot.active_uploads > 0; let draw_result = { - let _terminal_guard = TERMINAL_IO.lock(); + let _render_terminal_guard = TERMINAL_IO.lock(); if !is_active() { return; } @@ -223,6 +223,9 @@ pub fn start( } } }); + // The task owns the terminal until console cleanup stops its loop. + // Dropping this handle intentionally detaches it from the startup caller. + drop(render_task); redraw.notify_one(); Ok((key_rx, redraw)) diff --git a/src/server/console/operator_views.rs b/src/server/console/operator_views.rs index 3db8315d..2728ff2a 100644 --- a/src/server/console/operator_views.rs +++ b/src/server/console/operator_views.rs @@ -145,7 +145,7 @@ pub(super) fn render_tasks( .render(regions.get(1).copied().unwrap_or(area), frame.buffer_mut()); let body = regions.get(2).copied().unwrap_or(area); let columns = Layout::horizontal([Constraint::Percentage(64), Constraint::Percentage(36)]) - .spacing(1) + .spacing(1_i32) .split(body); let table_area = if body.width >= 110 { columns.first().copied().unwrap_or(body) diff --git a/src/server/console/state.rs b/src/server/console/state.rs index ffdbf0ce..0fcbcabd 100644 --- a/src/server/console/state.rs +++ b/src/server/console/state.rs @@ -65,6 +65,10 @@ pub enum NoticeSeverity { /// Feedback shown beneath the main navigation. #[derive(Clone, Debug, PartialEq, Eq)] +#[expect( + clippy::partial_pub_fields, + reason = "preserve the existing feedback presentation API while keeping creation time private so callers cannot bypass notice expiry bookkeeping" +)] pub struct Notice { /// Visual and semantic severity. pub severity: NoticeSeverity, @@ -280,7 +284,7 @@ impl ConsoleState { Screen::Boards => Some(&mut self.board_filter), Screen::Tasks => Some(&mut self.task_filter), Screen::Logs => Some(&mut self.log_filter), - _ => None, + Screen::Dashboard | Screen::System | Screen::Configuration | Screen::Help => None, } } @@ -300,7 +304,7 @@ impl ConsoleState { format!("Route: /{short}/"), "Manage board policies and moderation in the authenticated /admin interface.".to_owned(), "C creates a board; D selects a thread for confirmed deletion.".to_owned(), ])), - _ => None, + Screen::Dashboard | Screen::System | Screen::Configuration | Screen::Help => None, }; if let Some((title, lines)) = detail { self.dialog = Some(Dialog::Inspect { @@ -326,9 +330,14 @@ impl ConsoleState { .position(|screen| *screen == self.screen) .unwrap_or(0); let next = if backward { - (index + screens.len() - 1) % screens.len() + index + .checked_sub(1) + .unwrap_or_else(|| screens.len().saturating_sub(1)) } else { - (index + 1) % screens.len() + index + .saturating_add(1) + .checked_rem(screens.len()) + .unwrap_or(0) }; if let Some(screen) = screens.get(next) { self.screen = *screen; @@ -356,7 +365,18 @@ impl ConsoleState { self.dialog = None; match result { Ok(message) => { - if matches!(request, OperationRequest::CreateBoard { .. }) { + if matches!( + request, + OperationRequest::CreateBoard { + short: _, + name: _, + description: _, + nsfw: _, + allow_images: _, + allow_video: _, + allow_audio: _ + } + ) { self.screen = Screen::Boards; } self.set_notice(NoticeSeverity::Success, message); @@ -457,7 +477,52 @@ impl ConsoleState { KeyEvent::RepeatCharacter(character) => { self.handle_screen_key(&KeyEvent::Character(*character), board_count); } - _ => self.handle_screen_key(key, board_count), + KeyEvent::Character(_) + | KeyEvent::Paste(_) + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Up + | KeyEvent::Down + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::PageUp + | KeyEvent::PageDown + | KeyEvent::Home + | KeyEvent::End + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => self.handle_screen_key(key, board_count), + } + ConsoleAction::None + } + + /// Confirm or dismiss shutdown without interpreting held keys as consent. + fn handle_quit_confirmation(&mut self, key: &KeyEvent) -> ConsoleAction { + match key { + KeyEvent::Enter | KeyEvent::Character('y' | 'Y') => { + return ConsoleAction::Shutdown { forced: false }; + } + KeyEvent::Escape | KeyEvent::Character('n' | 'N' | 'q' | 'Q') => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Up + | KeyEvent::Down + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::PageUp + | KeyEvent::PageDown + | KeyEvent::Home + | KeyEvent::End + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => self.dialog = Some(Dialog::ConfirmQuit), } ConsoleAction::None } @@ -465,7 +530,11 @@ impl ConsoleState { /// Handle an input event while a modal is active. fn handle_dialog(&mut self, mut dialog: Dialog, key: &KeyEvent) -> ConsoleAction { match &mut dialog { - Dialog::Inspect { scroll, .. } => { + Dialog::Inspect { + scroll, + title: _, + lines: _, + } => { match key { KeyEvent::Escape | KeyEvent::Character('q') => return ConsoleAction::None, KeyEvent::Up | KeyEvent::Character('k') => *scroll = scroll.saturating_sub(1), @@ -473,17 +542,25 @@ impl ConsoleState { KeyEvent::PageUp => *scroll = scroll.saturating_sub(5), KeyEvent::PageDown => *scroll = scroll.saturating_add(5), KeyEvent::Home => *scroll = 0, - _ => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::End + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} } self.dialog = Some(dialog); } - Dialog::ConfirmQuit => match key { - KeyEvent::Enter | KeyEvent::Character('y' | 'Y') => { - return ConsoleAction::Shutdown { forced: false }; - } - KeyEvent::Escape | KeyEvent::Character('n' | 'N' | 'q' | 'Q') => {} - _ => self.dialog = Some(dialog), - }, + Dialog::ConfirmQuit => return self.handle_quit_confirmation(key), Dialog::ConfirmDelete { thread_id } => match key { KeyEvent::Character('y' | 'Y') => { let request = OperationRequest::DeleteThread { @@ -495,9 +572,28 @@ impl ConsoleState { return ConsoleAction::Submit(request); } KeyEvent::Escape | KeyEvent::Character('n' | 'N') => {} - _ => self.dialog = Some(dialog), + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Up + | KeyEvent::Down + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::PageUp + | KeyEvent::PageDown + | KeyEvent::Home + | KeyEvent::End + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => self.dialog = Some(dialog), }, - Dialog::Progress { .. } => self.dialog = Some(dialog), + Dialog::Progress { label: _ } => self.dialog = Some(dialog), Dialog::Form(form) => { if matches!(key, KeyEvent::Escape) { return ConsoleAction::None; @@ -540,7 +636,64 @@ impl ConsoleState { self.tasks.selected_short = None; self.tasks.selected = (count > 0).then_some(count.saturating_sub(1)); } - _ => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::Escape + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} + } + } + + /// Move board-table selection while preserving its bounds and identity. + fn handle_boards_key(&mut self, key: &KeyEvent, board_count: usize) { + match key { + KeyEvent::Up | KeyEvent::Character('k' | 'K') => { + self.boards.move_by(-1, board_count); + } + KeyEvent::Down | KeyEvent::Character('j' | 'J') => { + self.boards.move_by(1, board_count); + } + KeyEvent::PageUp => self.boards.page_by(-1, board_count), + KeyEvent::PageDown => self.boards.page_by(1, board_count), + KeyEvent::Home => { + self.boards.selected_short = None; + self.boards.reconcile(board_count); + if board_count > 0 { + self.boards.selected = Some(0); + } + } + KeyEvent::End => { + self.boards.selected_short = None; + self.boards.reconcile(board_count); + if board_count > 0 { + self.boards.selected = Some(board_count.saturating_sub(1)); + } + } + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::Escape + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} } } @@ -548,31 +701,7 @@ impl ConsoleState { fn handle_screen_key(&mut self, key: &KeyEvent, board_count: usize) { match self.screen { Screen::Tasks => self.handle_tasks_key(key), - Screen::Boards => match key { - KeyEvent::Up | KeyEvent::Character('k' | 'K') => { - self.boards.move_by(-1, board_count); - } - KeyEvent::Down | KeyEvent::Character('j' | 'J') => { - self.boards.move_by(1, board_count); - } - KeyEvent::PageUp => self.boards.page_by(-1, board_count), - KeyEvent::PageDown => self.boards.page_by(1, board_count), - KeyEvent::Home => { - self.boards.selected_short = None; - self.boards.reconcile(board_count); - if board_count > 0 { - self.boards.selected = Some(0); - } - } - KeyEvent::End => { - self.boards.selected_short = None; - self.boards.reconcile(board_count); - if board_count > 0 { - self.boards.selected = Some(board_count.saturating_sub(1)); - } - } - _ => {} - }, + Screen::Boards => self.handle_boards_key(key, board_count), Screen::Logs => match key { KeyEvent::Up | KeyEvent::Character('k' | 'K') => { self.logs.rows_from_bottom = self.logs.rows_from_bottom.saturating_add(1); @@ -606,7 +735,19 @@ impl ConsoleState { self.logs.rows_from_bottom = 0; self.logs.follow = true; } - _ => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Escape + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} }, Screen::Dashboard | Screen::Help | Screen::System | Screen::Configuration => { let offset = if self.screen == Screen::Help { @@ -627,7 +768,21 @@ impl ConsoleState { KeyEvent::PageDown => *offset = offset.saturating_add(10), KeyEvent::Home => *offset = 0, KeyEvent::End => *offset = u16::MAX, - _ => {} + KeyEvent::Character(_) + | KeyEvent::RepeatCharacter(_) + | KeyEvent::Paste(_) + | KeyEvent::Enter + | KeyEvent::Tab + | KeyEvent::BackTab + | KeyEvent::Backspace + | KeyEvent::Delete + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::Escape + | KeyEvent::ClearLine + | KeyEvent::Submit + | KeyEvent::ForceQuit + | KeyEvent::Resize => {} } } } @@ -702,9 +857,20 @@ impl OperationRequest { /// Return the present-tense progress label. const fn progress_label(&self) -> &'static str { match self { - Self::CreateBoard { .. } => "Creating board…", - Self::CreateAdmin { .. } => "Securing administrator credentials…", - Self::DeleteThread { .. } => "Deleting thread and attached files…", + Self::CreateBoard { + short: _, + name: _, + description: _, + nsfw: _, + allow_images: _, + allow_video: _, + allow_audio: _, + } => "Creating board…", + Self::CreateAdmin { + username: _, + password: _, + } => "Securing administrator credentials…", + Self::DeleteThread { thread_id: _ } => "Deleting thread and attached files…", } } @@ -713,7 +879,19 @@ impl OperationRequest { pub const fn refreshes_stats(&self) -> bool { matches!( self, - Self::CreateBoard { .. } | Self::DeleteThread { .. } | Self::CreateAdmin { .. } + Self::CreateBoard { + short: _, + name: _, + description: _, + nsfw: _, + allow_images: _, + allow_video: _, + allow_audio: _ + } | Self::DeleteThread { thread_id: _ } + | Self::CreateAdmin { + username: _, + password: _ + } ) } } @@ -739,7 +917,10 @@ impl fmt::Debug for OperationRequest { .field("allow_video", allow_video) .field("allow_audio", allow_audio) .finish(), - Self::CreateAdmin { username, .. } => formatter + Self::CreateAdmin { + username, + password: _, + } => formatter .debug_struct("CreateAdmin") .field("username", username) .field("password", &"") diff --git a/src/server/console/state_tests.rs b/src/server/console/state_tests.rs index 4a8a6467..d2d291a1 100644 --- a/src/server/console/state_tests.rs +++ b/src/server/console/state_tests.rs @@ -56,7 +56,11 @@ fn log_scrolling_disables_follow_and_end_restores_it() { ..ConsoleState::default() }; - state.handle_key(&KeyEvent::PageUp, 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::PageUp, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert!( !state.logs.follow, "manual scrolling should pause follow mode" @@ -66,7 +70,11 @@ fn log_scrolling_disables_follow_and_end_restores_it() { "page-up should move ten rows" ); - state.handle_key(&KeyEvent::End, 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::End, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert!(state.logs.follow, "end should resume follow mode"); assert_eq!( state.logs.rows_from_bottom, 0, @@ -96,7 +104,11 @@ fn passwords_are_redacted_from_debug_output() { #[test] fn delete_thread_uses_a_separate_destructive_confirmation() { let mut state = ConsoleState::default(); - state.handle_key(&KeyEvent::Character('d'), 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::Character('d'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); let form_dialog = state.dialog.take(); assert!( matches!(&form_dialog, Some(Dialog::Form(_))), @@ -194,7 +206,7 @@ fn repeated_enter_cannot_accept_destructive_confirmation() { "progress must prevent duplicate operations" ); assert!( - matches!(state.dialog, Some(Dialog::Progress { .. })), + matches!(state.dialog, Some(Dialog::Progress { label: _ })), "in-flight operation must retain its progress state" ); } @@ -268,18 +280,30 @@ fn form_editing_preserves_unicode_and_limits_large_paste() { #[test] fn form_focus_and_repeat_keys_do_not_trigger_global_actions() { let mut state = ConsoleState::default(); - state.handle_key(&KeyEvent::RepeatCharacter('a'), 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::RepeatCharacter('a'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert!( state.dialog.is_none(), "a held action key must not reopen forms" ); - state.handle_key(&KeyEvent::Character('a'), 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::Character('a'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); for key in [ KeyEvent::BackTab, KeyEvent::RepeatCharacter('q'), KeyEvent::Character('1'), ] { - state.handle_key(&key, 0, (80, 24)); + assert_eq!( + state.handle_key(&key, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); } assert!( matches!(state.dialog, Some(Dialog::Form(_))), @@ -294,7 +318,11 @@ fn form_focus_and_repeat_keys_do_not_trigger_global_actions() { Ok("q1"), "global shortcuts and repeated text belong to the focused field" ); - state.handle_key(&KeyEvent::Escape, 0, (80, 24)); + assert_eq!( + state.handle_key(&KeyEvent::Escape, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert!( state.dialog.is_none(), "escape must cancel without submission" @@ -311,26 +339,46 @@ fn suite_navigation_and_contextual_help_preserve_the_origin() { Screen::Configuration, Screen::Dashboard, ] { - app.handle_key(&KeyEvent::Tab, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Tab, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.screen, expected, "Tab must traverse the shared suite hierarchy" ); } - app.handle_key(&KeyEvent::Character('2'), 0, (80, 24)); - app.handle_key(&KeyEvent::Character('?'), 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Character('2'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); + assert_eq!( + app.handle_key(&KeyEvent::Character('?'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.help_return, Some(Screen::Tasks), "help must remember its origin" ); - app.handle_key(&KeyEvent::Escape, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Escape, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.screen, Screen::Tasks, "closing help must return to the task table" ); - app.handle_key(&KeyEvent::BackTab, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::BackTab, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.screen, Screen::Dashboard, @@ -364,13 +412,21 @@ fn search_is_modal_and_escape_clears_before_navigating() { app.task_filter.query, "qad123q", "search must accept literal shortcut characters" ); - app.handle_key(&KeyEvent::Escape, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Escape, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.screen, Screen::Tasks, "first Escape must clear search in place" ); - app.handle_key(&KeyEvent::Escape, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Escape, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert_eq!( app.screen, Screen::Dashboard, @@ -415,15 +471,37 @@ fn task_identity_survives_refresh_and_inspection_has_no_side_effects() { "inspection must be read only" ); assert!( - matches!(app.dialog, Some(Dialog::Inspect { .. })), + matches!( + app.dialog, + Some(Dialog::Inspect { + title: _, + lines: _, + scroll: _ + }) + ), "selected task must open details" ); - app.handle_key(&KeyEvent::Character('d'), 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Character('d'), 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); assert!( - matches!(app.dialog, Some(Dialog::Inspect { .. })), + matches!( + app.dialog, + Some(Dialog::Inspect { + title: _, + lines: _, + scroll: _ + }) + ), "detail must capture administrative shortcuts" ); - app.handle_key(&KeyEvent::Escape, 0, (80, 24)); + assert_eq!( + app.handle_key(&KeyEvent::Escape, 0, (80, 24)), + ConsoleAction::None, + "navigation and editing must not request server-side work" + ); stats.operator.tasks.clear(); app.reconcile_data(&stats); assert!( diff --git a/src/server/console/stats.rs b/src/server/console/stats.rs index 7e8d427b..ff2fedc4 100644 --- a/src/server/console/stats.rs +++ b/src/server/console/stats.rs @@ -303,16 +303,17 @@ mod tests { drop(connection); let snapshot = read_database_stats(&pool)?; assert_eq!(snapshot.boards, 1, "healthy board count must be retained"); - let connection = pool.get()?; - connection.execute("UPDATE boards SET short_name = NULL", [])?; - drop(connection); + let malformed_connection = pool.get()?; + let _changed_rows = + malformed_connection.execute("UPDATE boards SET short_name = NULL", [])?; + drop(malformed_connection); assert!( read_database_stats(&pool).is_err(), "a malformed board row must fail the whole snapshot" ); - let connection = pool.get()?; - connection.execute("DROP TABLE posts", [])?; - drop(connection); + let incomplete_connection = pool.get()?; + let _dropped_table_rows = incomplete_connection.execute("DROP TABLE posts", [])?; + drop(incomplete_connection); assert!( read_database_stats(&pool).is_err(), "SQL errors must not turn into healthy zero counts" diff --git a/src/server/console/telemetry.rs b/src/server/console/telemetry.rs index ef6b76df..0a89ae4f 100644 --- a/src/server/console/telemetry.rs +++ b/src/server/console/telemetry.rs @@ -217,10 +217,10 @@ impl OperatorSnapshot { )) }, )?; - let mut statement = transaction.prepare_cached( + let mut moderation_statement = transaction.prepare_cached( "SELECT created_at, action, target_type, target_id FROM mod_log ORDER BY id DESC LIMIT 8" )?; - let moderation = statement + let moderation = moderation_statement .query_map([], |row| { Ok(format!( "{} · {} · {} #{}", @@ -231,7 +231,7 @@ impl OperatorSnapshot { )) })? .collect::>>()?; - drop(statement); + drop(moderation_statement); transaction.commit()?; Ok(Self { jobs: Some(jobs), @@ -327,7 +327,7 @@ fn maintenance_task(status: DbMaintenanceJobStatus) -> Option { DbMaintenanceJobStatus::Failed { job_id, finished_at, - .. + message: _, } => ( job_id, TaskState::Failed, @@ -401,7 +401,7 @@ mod tests { r#"{"t":"SpamCheck","d":{"post_id":42,"ip_hash":"PRIVATE_HASH","body_len":10}}"#; let id = crate::db::enqueue_job(&connection, "spam_check", payload)?; for state in ["pending", "running", "done", "failed"] { - connection.execute( + let _changed_rows = connection.execute( "UPDATE background_jobs SET status = ?1 WHERE id = ?2", rusqlite::params![state, id], )?; @@ -425,7 +425,7 @@ mod tests { let pool = crate::db::init_test_pool()?; let connection = pool.get()?; for id in 1..=100_i64 { - connection.execute("INSERT INTO background_jobs (job_type, payload, status, created_at, updated_at) VALUES ('spam_check', '{}', 'done', ?1, ?1)", [id])?; + let _changed_rows = connection.execute("INSERT INTO background_jobs (job_type, payload, status, created_at, updated_at) VALUES ('spam_check', '{}', 'done', ?1, ?1)", [id])?; } let snapshot = OperatorSnapshot::read(&pool)?; anyhow::ensure!( @@ -439,7 +439,7 @@ mod tests { .is_some_and(|task| task.id == "job:100"), "newest terminal job must appear first" ); - connection.execute("DROP TABLE mod_log", [])?; + let _changed_rows = connection.execute("DROP TABLE mod_log", [])?; anyhow::ensure!( OperatorSnapshot::read(&pool).is_err(), "query failure must not become a healthy empty snapshot" @@ -479,10 +479,10 @@ mod tests { .mark_failed(job_id, "fixture failure".to_owned()), "matching job should transition" ); - let mut snapshot = OperatorSnapshot::default(); - snapshot.attach_runtime(&app); + let mut failure_snapshot = OperatorSnapshot::default(); + failure_snapshot.attach_runtime(&app); anyhow::ensure!( - snapshot + failure_snapshot .tasks .iter() .any(|task| task.state == TaskState::Failed), diff --git a/src/server/console/widgets.rs b/src/server/console/widgets.rs index 5f6c84e3..a30bf156 100644 --- a/src/server/console/widgets.rs +++ b/src/server/console/widgets.rs @@ -56,7 +56,10 @@ pub(super) fn render_label_rows( label_width: u16, ) { let table_rows = rows.into_iter().map(|(label, value)| { - let wrapped = wrap_styled_line(&value, area.width.saturating_sub(label_width + 1)); + let wrapped = wrap_styled_line( + &value, + area.width.saturating_sub(label_width.saturating_add(1)), + ); let height = u16::try_from(wrapped.len()).unwrap_or(u16::MAX).max(1); Row::new(vec![ Cell::from(label).style(Style::default().fg(MUTED)), @@ -329,7 +332,7 @@ pub(super) fn format_number_signed_compact(number: i64) -> String { pub(super) fn spinner_frame(tick: u8) -> &'static str { const FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; FRAMES - .get(usize::from(tick) % FRAMES.len()) + .get(usize::from(tick).checked_rem(FRAMES.len()).unwrap_or(0)) .copied() .unwrap_or("|") } @@ -339,7 +342,7 @@ pub(super) fn spinner_frame(tick: u8) -> &'static str { pub(super) fn spinner_frame(tick: u8) -> &'static str { const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; FRAMES - .get(usize::from(tick) % FRAMES.len()) + .get(usize::from(tick).checked_rem(FRAMES.len()).unwrap_or(0)) .copied() .unwrap_or("⠋") } @@ -360,40 +363,40 @@ pub(super) fn wrap_lines(lines: &[String], width: u16) -> Vec { fn wrap_styled_line(line: &Line<'_>, width: u16) -> Vec> { let width = usize::from(width).max(1); let mut words = Vec::new(); - let mut word = Vec::new(); + let mut pending_word = Vec::new(); for grapheme in line.styled_graphemes(Style::default()) { if grapheme.symbol.chars().all(char::is_whitespace) { - if !word.is_empty() { - words.push(std::mem::take(&mut word)); + if !pending_word.is_empty() { + words.push(std::mem::take(&mut pending_word)); } } else { - word.push(Span::styled(grapheme.symbol.to_owned(), grapheme.style)); + pending_word.push(Span::styled(grapheme.symbol.to_owned(), grapheme.style)); } } - if !word.is_empty() { - words.push(word); + if !pending_word.is_empty() { + words.push(pending_word); } let mut lines = Vec::new(); let mut spans = Vec::new(); - let mut columns = 0; + let mut columns = 0_usize; for word in words { let word_width: usize = word.iter().map(Span::width).sum(); - if columns > 0 && columns + 1 + word_width > width { + if columns > 0 && columns.saturating_add(1).saturating_add(word_width) > width { lines.push(Line::from(std::mem::take(&mut spans))); columns = 0; } if columns > 0 { spans.push(Span::raw(" ")); - columns += 1; + columns = columns.saturating_add(1); } for span in word { let size = span.width(); - if columns + size > width && !spans.is_empty() { + if columns.saturating_add(size) > width && !spans.is_empty() { lines.push(Line::from(std::mem::take(&mut spans))); columns = 0; } spans.push(span); - columns += size; + columns = columns.saturating_add(size); } } lines.push(Line::from(spans)); @@ -408,7 +411,9 @@ pub(super) fn label_rows_height( ) -> u16 { let height: usize = rows .iter() - .map(|(_, value)| wrap_styled_line(value, width.saturating_sub(label_width + 1)).len()) + .map(|(_, value)| { + wrap_styled_line(value, width.saturating_sub(label_width.saturating_add(1))).len() + }) .sum(); u16::try_from(height).unwrap_or(u16::MAX).saturating_add(2) } diff --git a/src/server/console/wizard.rs b/src/server/console/wizard.rs index 510dfdfb..b4a97eb8 100644 --- a/src/server/console/wizard.rs +++ b/src/server/console/wizard.rs @@ -272,7 +272,7 @@ fn prompt_terminal(label: &str, secret: bool) -> Option { value.clear(); } KeyCode::Backspace => { - value.pop(); + let _removed_character = value.pop(); } KeyCode::Char(character) if !key @@ -283,7 +283,31 @@ fn prompt_terminal(label: &str, secret: bool) -> Option { { value.push(character); } - _ => {} + KeyCode::Enter + | KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Home + | KeyCode::End + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Tab + | KeyCode::BackTab + | KeyCode::Delete + | KeyCode::Insert + | KeyCode::F(_) + | KeyCode::Char(_) + | KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => {} } } Event::Paste(content) => { @@ -295,7 +319,11 @@ fn prompt_terminal(label: &str, secret: bool) -> Option { .take(remaining), ); } - _ => {} + Event::FocusGained + | Event::FocusLost + | Event::Key(_) + | Event::Mouse(_) + | Event::Resize(..) => {} } redraw_prompt(label, &value, secret); } diff --git a/src/server/server.rs b/src/server/server.rs index 0aa47491..8d450fc8 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -45,6 +45,24 @@ fn should_run_background_maintenance(state: &AppState, max_in_flight: u64) -> bo && IN_FLIGHT.load(Ordering::Relaxed) <= max_in_flight } +/// Validates an operator-configured interval before constructing timer state. +/// Zero remains available to callers as the existing disabled-period sentinel. +fn configured_timer_interval( + value: u64, + seconds_per_unit: u64, + label: &str, +) -> anyhow::Result { + let seconds = value + .checked_mul(seconds_per_unit) + .with_context(|| format!("{label} interval overflows"))?; + let interval = Duration::from_secs(seconds); + anyhow::ensure!( + tokio::time::Instant::now().checked_add(interval).is_some(), + "{label} interval exceeds the supported timer range" + ); + Ok(interval) +} + /// Initial retry delay for a failed scheduled full backup. const SCHEDULED_FULL_BACKUP_RETRY_BASE_SECS: u64 = 15 * 60; /// Maximum retry delay for a failed scheduled full backup. @@ -157,7 +175,7 @@ async fn tls_plaintext_backend_gate( "HTTPS is required", ) .into_response(); - response.headers_mut().insert( + let _previous_value = response.headers_mut().insert( header::CONNECTION, header::HeaderValue::from_static("close"), ); @@ -235,6 +253,25 @@ async fn run_server_lifecycle( // clear error rather than discovering misconfiguration at runtime (#8). CONFIG.validate()?; + let media_reconcile_interval = configured_timer_interval( + CONFIG.media_reconcile_interval_hours, + 3600, + "media reconciliation", + )?; + let vacuum_interval = + configured_timer_interval(CONFIG.auto_vacuum_interval_hours, 3600, "automatic VACUUM")?; + let wal_interval = + configured_timer_interval(CONFIG.wal_checkpoint_interval, 1, "WAL checkpoint")?; + let poll_cleanup_interval = + configured_timer_interval(CONFIG.poll_cleanup_interval_hours, 3600, "poll cleanup")?; + let _validated_backup_interval = configured_timer_interval( + CONFIG.auto_full_backup_interval_hours, + 3600, + "automatic full backup", + )?; + let poll_retention_cutoff_secs = i64::try_from(poll_cleanup_interval.as_secs()) + .context("poll retention interval exceeds supported timestamp range")?; + let data_dir = super::parent_dir_or_current(std::path::Path::new(&CONFIG.database_path)); std::fs::create_dir_all(&data_dir)?; @@ -512,7 +549,7 @@ async fn run_server_lifecycle( tracing::warn!( target: "server", bind_addr = %bind_addr, - fallback = 8080, + fallback = 8_080_i32, "Could not parse port from bind_addr — Tor proxy will use port 8080" ); 8080 @@ -612,6 +649,8 @@ async fn run_server_lifecycle( let worker_cancel = state.job_queue.cancel.clone(); let _cancel_on_exit = worker_cancel.clone().drop_guard(); let start_time = Instant::now(); + // Each JoinSet retains task ownership through shutdown; the optional + // per-task abort handles are intentionally unused. let mut background_tasks = tokio::task::JoinSet::new(); // Media reconciliation is deliberately paged and periodic: workers poll @@ -621,11 +660,11 @@ async fn run_server_lifecycle( let reconcile_pool = pool.clone(); let reconcile_queue = Arc::clone(&state.job_queue); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut job_cursor = startup_media_reconciliation.next_job_id; let mut post_cursor = startup_media_reconciliation.next_post_id; let mut interval = tokio::time::interval(Duration::from_mins(1)); - interval.tick().await; + let _completed_value = interval.tick().await; loop { tokio::select! { _ = interval.tick() => { @@ -679,11 +718,10 @@ async fn run_server_lifecycle( if CONFIG.media_reconcile_interval_hours > 0 { let reconcile_pool = pool.clone(); let cancel_clone = worker_cancel.clone(); - let interval_hours = CONFIG.media_reconcile_interval_hours; - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut cursor = startup_managed_media_cursor; - let mut interval = tokio::time::interval(Duration::from_hours(interval_hours)); - interval.tick().await; + let mut interval = tokio::time::interval(media_reconcile_interval); + let _completed_value = interval.tick().await; loop { tokio::select! { _ = interval.tick() => { @@ -732,10 +770,10 @@ async fn run_server_lifecycle( let reconcile_pool = pool.clone(); let reconcile_queue = Arc::clone(&state.job_queue); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut cursor = startup_prune_reconciliation.next_board_id; let mut interval = tokio::time::interval(Duration::from_mins(1)); - interval.tick().await; + let _completed_value = interval.tick().await; loop { tokio::select! { _ = interval.tick() => { @@ -787,7 +825,7 @@ async fn run_server_lifecycle( { let bg = pool.clone(); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut iv = tokio::time::interval(Duration::from_hours(1)); loop { tokio::select! { @@ -819,16 +857,16 @@ async fn run_server_lifecycle( if CONFIG.wal_checkpoint_interval > 0 { let bg = pool.clone(); let maintenance_state = state.clone(); - let interval_secs = CONFIG.wal_checkpoint_interval; + let interval_secs = wal_interval.as_secs(); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { // Stagger the first run by half the interval so it doesn't fire // immediately at startup alongside the session purge. tokio::select! { - () = tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 1)) => {} + () = tokio::time::sleep(Duration::from_secs((interval_secs / 2).saturating_add(1))) => {} () = cancel_clone.cancelled() => { return; } } - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + let mut iv = tokio::time::interval(wal_interval); loop { tokio::select! { _ = iv.tick() => { @@ -872,7 +910,7 @@ async fn run_server_lifecycle( // Background: prune stale IPs from ACTIVE_IPS every 5 min { let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut iv = tokio::time::interval(Duration::from_mins(5)); loop { tokio::select! { @@ -897,7 +935,7 @@ async fn run_server_lifecycle( // opportunistic prune path inside clear_login_fails). { let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut iv = tokio::time::interval(Duration::from_mins(5)); loop { tokio::select! { @@ -918,16 +956,16 @@ async fn run_server_lifecycle( if CONFIG.auto_vacuum_interval_hours > 0 { let bg = pool.clone(); let maintenance_state = state.clone(); - let interval_secs = CONFIG.auto_vacuum_interval_hours * 3600; + let interval_secs = vacuum_interval.as_secs(); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { // Stagger the first run by half the interval to avoid hammering the // DB immediately at startup alongside WAL checkpoint and session purge. tokio::select! { - () = tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 7)) => {} + () = tokio::time::sleep(Duration::from_secs((interval_secs / 2).saturating_add(7))) => {} () = cancel_clone.cancelled() => { return; } } - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + let mut iv = tokio::time::interval(vacuum_interval); loop { tokio::select! { _ = iv.tick() => { @@ -990,7 +1028,7 @@ async fn run_server_lifecycle( let bg = pool.clone(); let maintenance_state = state.clone(); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let scheduler_started_at = SystemTime::now(); let mut failure_streak = 0u32; let mut retry_not_before: Option = None; @@ -1009,7 +1047,13 @@ async fn run_server_lifecycle( continue; } - let interval = Duration::from_secs(settings.interval_hours.saturating_mul(3600)); + let interval = match configured_timer_interval(settings.interval_hours, 3600, "automatic full backup") { + Ok(interval) => interval, + Err(error) => { + tracing::error!(target: "admin", %error, "Scheduled full backup interval is invalid"); + continue; + } + }; let last_saved_at = crate::handlers::admin::latest_verified_full_backup_modified_time() .unwrap_or(scheduler_started_at); let due = SystemTime::now() @@ -1111,22 +1155,20 @@ async fn run_server_lifecycle( // poll_votes table from growing indefinitely. if CONFIG.poll_cleanup_interval_hours > 0 { let bg = pool.clone(); - let interval_secs = CONFIG.poll_cleanup_interval_hours * 3600; let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { tokio::select! { () = tokio::time::sleep(Duration::from_mins(10)) => {} // initial delay () = cancel_clone.cancelled() => { return; } } - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + let mut iv = tokio::time::interval(poll_cleanup_interval); loop { tokio::select! { _ = iv.tick() => { let bg2 = bg.clone(); - let retention_cutoff_secs = interval_secs.cast_signed(); let task_result = tokio::task::spawn_blocking(move || { if let Ok(conn) = bg2.get() { - let cutoff = chrono::Utc::now().timestamp() - retention_cutoff_secs; + let cutoff = chrono::Utc::now().timestamp().saturating_sub(poll_retention_cutoff_secs); match crate::db::cleanup_expired_poll_votes(&conn, cutoff) { Ok(n) if n > 0 => { tracing::info!(target: "polls", removed = n, "Expired poll vote rows purged"); @@ -1159,7 +1201,7 @@ async fn run_server_lifecycle( { let bg = pool.clone(); let cancel_clone = worker_cancel.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { tokio::select! { () = tokio::time::sleep(Duration::from_mins(5)) => {} () = cancel_clone.cancelled() => { return; } @@ -1207,7 +1249,7 @@ async fn run_server_lifecycle( let max_bytes = CONFIG.waveform_cache_max_bytes; let cancel_clone = worker_cancel.clone(); let bg = pool.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { tokio::select! { () = tokio::time::sleep(Duration::from_mins(30)) => {} // initial stagger () = cancel_clone.cancelled() => { return; } @@ -1296,7 +1338,7 @@ async fn run_server_lifecycle( // Install shutdown handling before raw first-run input can begin. let signal_cancel = worker_cancel.clone(); - background_tasks.spawn(async move { + let _signal_task_abort_handle = background_tasks.spawn(async move { tokio::select! { () = shutdown_signal() => signal_cancel.cancel(), () = signal_cancel.cancelled() => {}, @@ -1357,7 +1399,7 @@ async fn run_server_lifecycle( let cancel_stats = worker_cancel.clone(); let onion_addr = Arc::clone(&state.onion_address); let force_reload = Arc::clone(&force_reload_notify); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(3)); let mut sampler = super::console::stats::Sampler::new(start_time); loop { @@ -1408,7 +1450,7 @@ async fn run_server_lifecycle( let cancel_d = worker_cancel.clone(); let shutdown_tx = worker_cancel.clone(); let operation_pool = pool.clone(); - background_tasks.spawn(async move { + let _task_abort_handle = background_tasks.spawn(async move { loop { let next_key = tokio::select! { biased; @@ -1460,7 +1502,7 @@ async fn run_server_lifecycle( let state_for_work = Arc::clone(&console); let reload_after_work = Arc::clone(&force_reload); let redraw_after_work = Arc::clone(&redraw); - tokio::spawn(async move { + drop(tokio::spawn(async move { let result = tokio::task::spawn_blocking(move || { super::console::wizard::execute(&request_for_work, &pool_for_work) }) @@ -1474,7 +1516,7 @@ async fn run_server_lifecycle( reload_after_work.notify_one(); } redraw_after_work.notify_one(); - }); + })); } } } @@ -1537,7 +1579,7 @@ async fn run_server_lifecycle( "Admin panel available over HTTPS" ); - listener_tasks.spawn(async move { + let _task_abort_handle = listener_tasks.spawn(async move { ( "HTTPS", run_https_static(https_tcp, rustls_cfg, app_tls, cancel_tls) @@ -1562,7 +1604,7 @@ async fn run_server_lifecycle( "Admin panel available over HTTPS (ACME)" ); - listener_tasks.spawn(async move { + let _task_abort_handle = listener_tasks.spawn(async move { ( "HTTPS/ACME", run_https_acme(https_tcp, acme_acceptor, server_cfg, app_tls, cancel_tls) @@ -1594,7 +1636,7 @@ async fn run_server_lifecycle( anyhow::anyhow!("Failed to bind HTTP→HTTPS redirect listener on {http_addr}: {e}") })?; tracing::info!(target: "server", addr = %http_addr, "HTTP→HTTPS redirect listening"); - listener_tasks.spawn(async move { + let _task_abort_handle = listener_tasks.spawn(async move { ( "HTTP redirect", run_http_redirect(http_listener, https_port, cancel_redirect) @@ -1607,7 +1649,7 @@ async fn run_server_lifecycle( let wait_shutdown = worker_cancel.clone(); if let Some((listener, app)) = plaintext_server { let serve_cancel = worker_cancel.clone(); - listener_tasks.spawn(async move { + let _task_abort_handle = listener_tasks.spawn(async move { ( "HTTP", run_plain_http(listener, app, serve_cancel) @@ -1631,7 +1673,9 @@ async fn run_server_lifecycle( () = wait_shutdown.cancelled() => Ok(()), result = next_listener_exit(&mut listener_tasks, &worker_cancel) => result, }; - let deadline = tokio::time::Instant::now() + crate::restart::SHUTDOWN_TIMEOUT; + let deadline = tokio::time::Instant::now() + .checked_add(crate::restart::SHUTDOWN_TIMEOUT) + .ok_or_else(|| anyhow::anyhow!("shutdown deadline overflows"))?; worker_cancel.cancel(); let listener_shutdown_result = finish_listener_tasks(&mut listener_tasks).await; tracing::info!(target: "server", "Draining background workers and persistent operations"); @@ -1722,8 +1766,8 @@ fn seed_initial_default_theme(conn: &rusqlite::Connection, initial_default_theme fn configure_http_limits( builder: &mut hyper_util::server::conn::auto::Builder, ) { - builder.http1().max_buf_size(headers::HTTP_MAX_HEADER_BYTES); - builder + let _configured_http1 = builder.http1().max_buf_size(headers::HTTP_MAX_HEADER_BYTES); + let _configured_http2 = builder .http2() .max_header_list_size(u32::try_from(headers::HTTP_MAX_HEADER_BYTES).unwrap_or(u32::MAX)); } @@ -1774,8 +1818,11 @@ async fn finish_listener_tasks( first_error.map_or(Ok(()), Err) }; - if let Ok(result) = - tokio::time::timeout(crate::restart::HTTP_DRAIN + Duration::from_secs(2), drain).await + if let Ok(result) = tokio::time::timeout( + crate::restart::HTTP_DRAIN.saturating_add(Duration::from_secs(2)), + drain, + ) + .await { return result; } @@ -1795,10 +1842,10 @@ async fn run_plain_http( ) -> std::io::Result<()> { let handle = axum_server::Handle::new(); let shutdown_handle = handle.clone(); - tokio::spawn(async move { + drop(tokio::spawn(async move { cancel.cancelled().await; shutdown_handle.graceful_shutdown(Some(crate::restart::HTTP_DRAIN)); - }); + })); let std_listener = listener.into_std()?; let mut server = axum_server::from_tcp(std_listener)?; @@ -1836,10 +1883,10 @@ pub async fn run_https_static( // Wire graceful shutdown to the same CancellationToken that controls // background workers and the HTTP listener. - tokio::spawn(async move { + drop(tokio::spawn(async move { cancel.cancelled().await; handle_clone.graceful_shutdown(Some(crate::restart::HTTP_DRAIN)); - }); + })); // Convert tokio TcpListener → std TcpListener for axum_server::from_tcp_rustls. // set_nonblocking(true) is required — axum-server expects a non-blocking socket. @@ -1891,7 +1938,7 @@ pub async fn run_https_acme( let server_cfg = Arc::clone(&server_cfg); let svc = app.clone(); - connections.spawn(async move { + let _task_abort_handle = connections.spawn(async move { use tokio_util::compat::{TokioAsyncReadCompatExt as _, FuturesAsyncReadCompatExt as _}; // rustls-acme requires futures::{AsyncRead, AsyncWrite}; wrap // the tokio TcpStream with the tokio-util compat shim. @@ -1908,15 +1955,15 @@ pub async fn run_https_acme( // per-request — axum::Router is Arc-backed so this is cheap. let svc = hyper::service::service_fn(move |req: hyper::Request| { let (mut parts, body) = req.into_parts(); - parts.extensions.insert(axum::extract::ConnectInfo(peer_addr)); - let req = axum::extract::Request::from_parts( + let _previous_value = parts.extensions.insert(axum::extract::ConnectInfo(peer_addr)); + let request = axum::extract::Request::from_parts( parts, axum::body::Body::new(body), ); - svc.clone().call(req) + svc.clone().call(request) }); let mut builder = http1::Builder::new(); - builder.max_buf_size(headers::HTTP_MAX_HEADER_BYTES); + let _configured = builder.max_buf_size(headers::HTTP_MAX_HEADER_BYTES); let connection_result = builder.serve_connection(io, svc).await; if let Err(e) = connection_result { tracing::debug!( @@ -2151,7 +2198,7 @@ fn is_local_redirect_host(host: &str) -> bool { /// - The onion address is not yet known (Arti still bootstrapping) /// - The request already came in via the onion address (no double-redirect) /// -/// Spec: +/// Spec: . async fn onion_location_middleware( axum::extract::State(state): axum::extract::State, req: axum::extract::Request, @@ -2187,7 +2234,8 @@ async fn onion_location_middleware( if is_html && !already_on_onion { if let Ok(val) = header::HeaderValue::from_str(&format!("http://{addr}")) { - resp.headers_mut() + let _previous_value = resp + .headers_mut() .insert(header::HeaderName::from_static("onion-location"), val); } } @@ -2199,11 +2247,12 @@ async fn onion_location_middleware( #[cfg(test)] mod tests { use super::{ - build_redirect_response, format_redirect_authority, next_listener_exit, - protect_tls_plaintext_backend, redirect_host, redirect_trusted_hosts_with, run_plain_http, - scheduled_full_backup_failure_retry_delay, seed_initial_default_theme, - PlaintextAppListener, + build_redirect_response, configured_timer_interval, format_redirect_authority, + next_listener_exit, protect_tls_plaintext_backend, redirect_host, + redirect_trusted_hosts_with, run_plain_http, scheduled_full_backup_failure_retry_delay, + seed_initial_default_theme, PlaintextAppListener, }; + use axum::{ body::Body, extract::{ConnectInfo, Request}, @@ -2216,6 +2265,28 @@ mod tests { use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tower::ServiceExt as _; + /// Disabled intervals and ordinary hours survive validation; huge values fail before scheduling. + #[test] + fn configured_timer_intervals_reject_overflow_and_preserve_disable() -> anyhow::Result<()> { + anyhow::ensure!( + configured_timer_interval(0, 3600, "test")? == Duration::ZERO, + "zero must preserve the disabled timer sentinel" + ); + anyhow::ensure!( + configured_timer_interval(24, 3600, "test")? == Duration::from_hours(24), + "valid hours must retain their exact duration" + ); + anyhow::ensure!( + configured_timer_interval(u64::MAX, 3600, "test").is_err(), + "hour-to-second overflow must fail before timer creation" + ); + anyhow::ensure!( + configured_timer_interval(u64::MAX, 1, "test").is_err(), + "an unrepresentable timer deadline must fail even when seconds fit u64" + ); + Ok(()) + } + /// Build a test request containing the supplied Host header. fn request_with_host(host: &str) -> anyhow::Result { Request::builder() @@ -2352,7 +2423,7 @@ mod tests { async fn tls_tor_backend_allows_only_registered_tor_proxy_peers() -> anyhow::Result<()> { let peer: SocketAddr = "127.0.0.1:61002".parse()?; let same_port_other_loopback: SocketAddr = "127.0.0.2:61002".parse()?; - crate::detect::TOR_STREAM_TOKENS.insert(peer, "tor:test".into()); + let _previous_value = crate::detect::TOR_STREAM_TOKENS.insert(peer, "tor:test".into()); let response = tls_backend_app(true) .oneshot(request_from_peer(peer)?) @@ -2360,7 +2431,7 @@ mod tests { let spoofed = tls_backend_app(true) .oneshot(request_from_peer(same_port_other_loopback)?) .await?; - crate::detect::TOR_STREAM_TOKENS.remove(&peer); + let _removed_stream_token = crate::detect::TOR_STREAM_TOKENS.remove(&peer); anyhow::ensure!(response.status() == StatusCode::NO_CONTENT); anyhow::ensure!(spoofed.status() == StatusCode::PERMANENT_REDIRECT); @@ -2395,7 +2466,8 @@ mod tests { let cancel = tokio_util::sync::CancellationToken::new(); let mut listeners: tokio::task::JoinSet = tokio::task::JoinSet::new(); - listeners.spawn(async { ("HTTPS", Err(anyhow::anyhow!("injected listener failure"))) }); + let _task_abort_handle = + listeners.spawn(async { ("HTTPS", Err(anyhow::anyhow!("injected listener failure"))) }); let Err(error) = next_listener_exit(&mut listeners, &cancel).await else { anyhow::bail!("unexpected listener failure must stop the runtime"); @@ -2495,7 +2567,8 @@ mod tests { fn initial_default_theme_skips_disabled_theme() -> anyhow::Result<()> { let conn = test_conn()?; conn.execute("UPDATE themes SET enabled = 0 WHERE slug = 'terminal'", []) - .map_err(|error| anyhow::anyhow!("disable terminal theme: {error}"))?; + .map_err(|error| anyhow::anyhow!("disable terminal theme: {error}")) + .map(|_completed_value| ())?; seed_initial_default_theme(&conn, "terminal"); @@ -2582,11 +2655,11 @@ mod shutdown_tests { let app = Router::new().route( "/", get(move || { - let accepted = Arc::clone(&handler_accepted); - let release = Arc::clone(&handler_release); + let request_accepted = Arc::clone(&handler_accepted); + let resume_response = Arc::clone(&handler_release); async move { - accepted.notify_one(); - release.notified().await; + request_accepted.notify_one(); + resume_response.notified().await; "completed" } }), diff --git a/src/server/server/headers.rs b/src/server/server/headers.rs index b92e4f75..4d442249 100644 --- a/src/server/server/headers.rs +++ b/src/server/server/headers.rs @@ -113,8 +113,8 @@ pub(super) async fn theme_error_response( ), }; *response.body_mut() = axum::body::Body::from(html); - response.headers_mut().remove(header::CONTENT_LENGTH); - response.headers_mut().insert( + let _previous_content_length = response.headers_mut().remove(header::CONTENT_LENGTH); + let _previous_value = response.headers_mut().insert( header::CACHE_CONTROL, header::HeaderValue::from_static("private, no-store"), ); @@ -153,7 +153,7 @@ fn boundary_error_response( message: &'static str, ) -> axum::response::Response { let mut response = (status, message).into_response(); - response.headers_mut().insert( + let _previous_value = response.headers_mut().insert( header::CONNECTION, header::HeaderValue::from_static("close"), ); @@ -175,7 +175,7 @@ pub(super) async fn hsts_middleware_with_mode( let mut resp = next.run(req).await; if emit_hsts { - resp.headers_mut().insert( + let _previous_value = resp.headers_mut().insert( header::HeaderName::from_static("strict-transport-security"), header::HeaderValue::from_static("max-age=31536000; includeSubDomains"), ); @@ -881,18 +881,18 @@ mod tests { return false; }; while let Some(relative_open) = search_tail.find(script_open) { - let open = search_from + relative_open; + let open = search_from.saturating_add(relative_open); let Some(after_open) = source.get(open..) else { break; }; let Some(tag_end_relative) = after_open.find('>') else { break; }; - let tag_end = open + tag_end_relative; + let tag_end = open.saturating_add(tag_end_relative); let Some(tag) = source.get(open..=tag_end) else { break; }; - let body_start = tag_end + 1; + let body_start = tag_end.saturating_add(1); let Some(body_tail) = source.get(body_start..) else { break; @@ -900,7 +900,7 @@ mod tests { let Some(close_relative) = body_tail.find(script_close) else { break; }; - let body_end = body_start + close_relative; + let body_end = body_start.saturating_add(close_relative); let Some(body) = source.get(body_start..body_end).map(str::trim) else { break; }; @@ -909,7 +909,7 @@ mod tests { return true; } - search_from = body_end + script_close.len(); + search_from = body_end.saturating_add(script_close.len()); let Some(next_tail) = source.get(search_from..) else { break; }; @@ -1004,7 +1004,7 @@ mod tests { .uri("/") .header("x-forwarded-proto", "https") .body(Body::empty())?; - request.extensions_mut().insert(ConnectInfo(SocketAddr::new( + let _previous_value = request.extensions_mut().insert(ConnectInfo(SocketAddr::new( IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)), 8080, ))); diff --git a/src/server/server/lifecycle.rs b/src/server/server/lifecycle.rs index 65eac6fa..9ede8a08 100644 --- a/src/server/server/lifecycle.rs +++ b/src/server/server/lifecycle.rs @@ -30,15 +30,15 @@ pub(super) async fn track_requests( { return recovery_unavailable(); } - REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); - IN_FLIGHT.fetch_add(1, Ordering::Relaxed); + let _previous_request_count = REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); + let _previous_in_flight_count = IN_FLIGHT.fetch_add(1, Ordering::Relaxed); let _in_flight_guard = ScopedDecrement(&IN_FLIGHT); let req_id = uuid::Uuid::new_v4().to_string(); let method = req.method().clone(); let path = req.uri().path().to_owned(); let mut req = req; - req.extensions_mut().insert(req_id.clone()); + let _previous_request_id = req.extensions_mut().insert(req_id.clone()); let span = tracing::info_span!( "request", req_id = %req_id, @@ -53,7 +53,7 @@ pub(super) async fn track_requests( h.update(real_ip.as_bytes()); let ip_hash = hex::encode(h.finalize()); if ACTIVE_IPS.len() < 10_000 { - ACTIVE_IPS.insert(ip_hash, Instant::now()); + let _previous_ip_activity = ACTIVE_IPS.insert(ip_hash, Instant::now()); } } @@ -64,13 +64,13 @@ pub(super) async fn track_requests( .is_some_and(|ct| ct.contains("multipart/form-data")); let _upload_guard = is_upload.then(|| { - ACTIVE_UPLOADS.fetch_add(1, Ordering::Relaxed); + let _previous_upload_count = ACTIVE_UPLOADS.fetch_add(1, Ordering::Relaxed); ScopedDecrement(&ACTIVE_UPLOADS) }); let mut response = next.run(req).instrument(span).await; if let Ok(value) = axum::http::HeaderValue::from_str(&req_id) { - response.headers_mut().insert(REQUEST_ID_HEADER, value); + let _previous_request_header = response.headers_mut().insert(REQUEST_ID_HEADER, value); } response } @@ -89,7 +89,7 @@ pub(super) async fn shutdown_signal() { let terminate = async { match signal::unix::signal(signal::unix::SignalKind::terminate()) { Ok(mut sig) => { - sig.recv().await; + let _previous_value = sig.recv().await; } Err(e) => { tracing::error!("Failed to register SIGTERM handler: {e}"); @@ -172,11 +172,11 @@ mod shutdown_tests { "running instance should admit HTTP work" ); state.job_queue.cancel.cancel(); - let response = app + let unavailable_response = app .oneshot(Request::builder().uri("/").body(Body::empty())?) .await?; anyhow::ensure!( - response.status() == StatusCode::SERVICE_UNAVAILABLE, + unavailable_response.status() == StatusCode::SERVICE_UNAVAILABLE, "shutdown must stop new work" ); Ok(()) diff --git a/src/server/server/observability.rs b/src/server/server/observability.rs index 285e72a8..08ae1024 100644 --- a/src/server/server/observability.rs +++ b/src/server/server/observability.rs @@ -77,7 +77,7 @@ pub(super) async fn healthz() -> impl IntoResponse { pub(super) async fn readyz(State(state): State) -> Response { let mut response = readyz_response(state, CONFIG.public_readiness_details).await; if let Ok(value) = axum::http::HeaderValue::from_str(&crate::restart::INSTANCE.to_string()) { - response.headers_mut().insert("x-rustchan-instance", value); + let _previous_value = response.headers_mut().insert("x-rustchan-instance", value); } response } @@ -233,11 +233,12 @@ async fn metrics_response(state: AppState) -> Response { move || -> (i64, i64, bool, i64, bool, i64) { let full_backups = list_backup_files(&full_backup_dir(), BackupListKind::Full); let full_backup_count = i64::try_from(full_backups.len()).unwrap_or(i64::MAX); - let latest_full_backup_verified = - full_backups.first().is_some_and(|backup| backup.verified); + let latest_full_backup_verified = full_backups + .first() + .is_some_and(|backup_file| backup_file.verified); let latest_full_backup_age_seconds = full_backups .first() - .and_then(|backup| backup.modified_epoch) + .and_then(|backup_file| backup_file.modified_epoch) .map_or(-1, |ts| { chrono::Utc::now().timestamp().saturating_sub(ts).max(0) }); diff --git a/src/server/server/router.rs b/src/server/server/router.rs index 2d7527e9..f31f2e4f 100644 --- a/src/server/server/router.rs +++ b/src/server/server/router.rs @@ -80,7 +80,8 @@ pub(super) fn build_router(state: AppState, direct_https: bool) -> Router { })) .layer(axum_middleware::from_fn( move |mut req: axum::extract::Request, next: axum_middleware::Next| async move { - req.extensions_mut() + let _previous_value = req + .extensions_mut() .insert(crate::middleware::RequestTransport { direct_https }); next.run(req).await }, @@ -143,7 +144,8 @@ mod tests { ) -> TestResult { let conn = state.db.get().context("get database connection")?; crate::db::create_board(&conn, short_name, "Board", "", false) - .context("create public media board")?; + .context("create public media board") + .map(|_completed_value| ())?; let boards = crate::db::get_all_boards(&conn).context("load live boards")?; crate::templates::set_live_boards(boards); Ok(()) @@ -163,7 +165,8 @@ mod tests { "UPDATE boards SET access_mode = ?1, access_password_hash = ?2 WHERE id = ?3", rusqlite::params!["view_password", password_hash, board_id], ) - .context("protect media board")?; + .context("protect media board") + .map(|_completed_value| ())?; let admin_hash = crate::utils::crypto::hash_password("hunter2").context("hash admin password")?; let admin_id = @@ -911,8 +914,12 @@ mod tests { let conn = state.db.get().context("get database connection")?; let password_hash = crate::utils::crypto::hash_password("hunter2").context("hash admin password")?; - crate::db::create_admin(&conn, "admin", &password_hash).context("create admin")?; - crate::db::create_board(&conn, "test", "Test", "", false).context("create board")?; + crate::db::create_admin(&conn, "admin", &password_hash) + .context("create admin") + .map(|_completed_value| ())?; + crate::db::create_board(&conn, "test", "Test", "", false) + .context("create board") + .map(|_completed_value| ())?; } let router = build_router(state, false); diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs index cac1130a..67d0e0d6 100644 --- a/src/server/server/routes.rs +++ b/src/server/server/routes.rs @@ -501,7 +501,10 @@ mod tests { writer .write_all(br#"{"version":1,"board":{"short_name":"b","name":"Random","description":"","nsfw":false,"thread_limit":100,"reply_limit":300,"bump_limit":300,"max_threads_per_ip":0,"require_thread_title":false,"enable_flags":false,"text_only":false,"forced_anon":false,"sage_without_cap":false,"max_file_size":0,"max_webm_size":0,"max_comment_chars":2000,"max_replies_per_thread":300,"max_subject_chars":100,"cooldown_seconds":0,"thread_cooldown_seconds":0,"show_thread_stats":false,"archive_threads":false,"public_logs":false,"allow_post_deletion":true,"allow_thread_deletion":true,"allow_media_uploads":true,"allow_polls":true,"default_name":"Anonymous","id":0},"threads":[],"posts":[],"polls":[],"file_hashes":[]}"#) .context("write board.json archive entry")?; - writer.finish().context("finish board backup archive")?; + writer + .finish() + .context("finish board backup archive") + .map(|_completed_value| ())?; } Ok(cursor.into_inner()) } @@ -517,7 +520,7 @@ mod tests { &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create administrator session")?; Ok(()) @@ -575,7 +578,8 @@ mod tests { let state = crate::test_support::app_state(); let conn = state.db.get().context("get database connection")?; crate::db::create_board(&conn, "test", "Test", "", false) - .context("create multipart envelope test board")?; + .context("create multipart envelope test board") + .map(|_completed_value| ())?; drop(conn); let app = public_routes().with_state(state); let boundary = "rustchan-envelope-test"; @@ -812,7 +816,7 @@ mod tests { "UPDATE boards SET access_mode = 'view_password', access_password_hash = ?1 WHERE id = ?2", rusqlite::params![original_hash, board_id], ) - .context("seed board access")?; + .context("seed board access").map(|_completed_value| ())?; } let response = post_board_settings( @@ -855,7 +859,7 @@ mod tests { "UPDATE boards SET access_mode = 'view_password', access_password_hash = ?1 WHERE id = ?2", rusqlite::params![original_hash, board_id], ) - .context("seed board access")?; + .context("seed board access").map(|_completed_value| ())?; } let response = post_board_settings( @@ -904,7 +908,8 @@ mod tests { "UPDATE boards SET access_password_hash = ?1 WHERE id = ?2", rusqlite::params![original_hash, board_id], ) - .context("seed board access")?; + .context("seed board access") + .map(|_completed_value| ())?; } let response = post_board_settings( @@ -947,7 +952,7 @@ mod tests { "UPDATE boards SET access_mode = 'view_password', access_password_hash = ?1 WHERE id = ?2", rusqlite::params![original_hash, board_id], ) - .context("seed board access")?; + .context("seed board access").map(|_completed_value| ())?; } let response = post_board_settings( @@ -1013,10 +1018,11 @@ mod tests { StatusCode::PAYLOAD_TOO_LARGE, "large backup should bypass the global media upload limit" ); - let body = to_bytes(response.into_body(), usize::MAX).await?; - let body = String::from_utf8(body.to_vec()).context("decode restore response body")?; + let response_bytes = to_bytes(response.into_body(), usize::MAX).await?; + let response_body = + String::from_utf8(response_bytes.to_vec()).context("decode restore response body")?; assert!( - body.contains("Board restore"), + response_body.contains("Board restore"), "restore response should identify the board restore flow" ); Ok(()) @@ -1070,12 +1076,14 @@ mod tests { .context("hash administrator password")?; let admin_id = crate::db::create_admin(&conn, "admin", &password_hash) .context("create administrator")?; - crate::db::create_board(&conn, "b", "Random", "", false).context("create board")?; + crate::db::create_board(&conn, "b", "Random", "", false) + .context("create board") + .map(|_completed_value| ())?; crate::db::create_session( &conn, "session123", admin_id, - chrono::Utc::now().timestamp() + 3600, + chrono::Utc::now().timestamp().saturating_add(3600), ) .context("create administrator session")?; } @@ -1131,22 +1139,23 @@ mod tests { "refresh should explain that the archive is a board backup" ); - let body = to_bytes(response.into_body(), usize::MAX).await?; - let body = String::from_utf8(body.to_vec()).context("decode restore error body")?; + let response_bytes = to_bytes(response.into_body(), usize::MAX).await?; + let response_body = + String::from_utf8(response_bytes.to_vec()).context("decode restore error body")?; assert!( - body.contains("Restore failed."), + response_body.contains("Restore failed."), "response body should report the restore failure" ); assert!( - body.contains("/admin/panel?restore_error="), + response_body.contains("/admin/panel?restore_error="), "response body should carry the panel redirect" ); assert!( - body.contains("open=full-backup-restore"), + response_body.contains("open=full-backup-restore"), "response body should reopen the full restore panel" ); assert!( - body.contains("#full-backup-restore"), + response_body.contains("#full-backup-restore"), "response body should target the full restore section" ); Ok(()) diff --git a/src/templates/admin.rs b/src/templates/admin.rs index 5870f2b5..b0f86fad 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -6,7 +6,6 @@ use crate::models::{ }; use crate::utils::{files::format_file_size, sanitize::escape_html}; use std::collections::BTreeSet; -use std::fmt::Write as _; use super::{base_layout, fmt_ts, fmt_ts_short, render_pagination, urlencoding_simple}; @@ -457,12 +456,14 @@ fn banner_target_type_options(selected: BannerTargetType) -> String { ]; let mut out = String::new(); for (value, label) in options { - let _ = write!( - out, - r#""#, - value = value.as_str(), - selected = if value == selected { " selected" } else { "" }, - label = label, + crate::templates::append_html( + &mut out, + format_args!( + r#""#, + value = value.as_str(), + selected = if value == selected { " selected" } else { "" }, + label = label, + ), ); } out @@ -483,36 +484,42 @@ fn banner_preview_html(asset: &BannerAsset, alt: &str) -> String { fn banner_board_options(boards: &[Board], selected_value: &str) -> String { let trimmed_selected = selected_value.trim().trim_matches('/'); let mut out = String::new(); - let _ = write!( - out, - r#""#, - if trimmed_selected.is_empty() { - " selected" - } else { - "" - } + crate::templates::append_html( + &mut out, + format_args!( + r#""#, + if trimmed_selected.is_empty() { + " selected" + } else { + "" + } + ), ); let board_exists = boards .iter() .any(|board| board.short_name == trimmed_selected); if !trimmed_selected.is_empty() && !board_exists { - let _ = write!( - out, - r#""#, - value = escape_html(trimmed_selected), + crate::templates::append_html( + &mut out, + format_args!( + r#""#, + value = escape_html(trimmed_selected), + ), ); } for board in boards { - let _ = write!( - out, - r#""#, - short = escape_html(&board.short_name), - selected = if board.short_name == trimmed_selected { - " selected" - } else { - "" - }, - name = escape_html(&board.name), + crate::templates::append_html( + &mut out, + format_args!( + r#""#, + short = escape_html(&board.short_name), + selected = if board.short_name == trimmed_selected { + " selected" + } else { + "" + }, + name = escape_html(&board.name), + ), ); } out @@ -838,8 +845,9 @@ fn render_board_settings_card( .checked_sub(1) .and_then(|prev| boards.get(prev)) .is_some_and(|prev| prev.nsfw == board.nsfw); - let next_same_group = boards - .get(index + 1) + let next_same_group = index + .checked_add(1) + .and_then(|next| boards.get(next)) .is_some_and(|next| next.nsfw == board.nsfw); let any_files_toggle = if crate::config::CONFIG.enable_any_file_uploads_feature { format!( @@ -1089,16 +1097,18 @@ fn render_board_appearance_card( ) -> String { let mut board_theme_options = String::new(); for theme in themes.iter().filter(|theme| theme.enabled) { - let _ = write!( - board_theme_options, - r#""#, - slug = escape_html(&theme.slug), - selected = if theme.slug == board.default_theme { - " selected" - } else { - "" - }, - label = escape_html(&theme.display_name) + crate::templates::append_html( + &mut board_theme_options, + format_args!( + r#""#, + slug = escape_html(&theme.slug), + selected = if theme.slug == board.default_theme { + " selected" + } else { + "" + }, + label = escape_html(&theme.display_name) + ), ); } let appearance_section = format!("board-appearance-{}", board.short_name); @@ -1230,9 +1240,10 @@ pub fn mod_log_page( } else { format!(r#"{s}"#, s = escape_html(&e.board_short)) }; - let _ = write!( - rows, - r#" + crate::templates::append_html( + &mut rows, + format_args!( + r#" {time} {admin} {action} @@ -1240,12 +1251,13 @@ pub fn mod_log_page( {board}
View detail

{detail}

"#, - time = escape_html(&fmt_ts(e.created_at)), - admin = escape_html(&e.admin_name), - action = escape_html(&e.action), - target = escape_html(&target), - board = board_link, - detail = escape_html(&e.detail) + time = escape_html(&fmt_ts(e.created_at)), + admin = escape_html(&e.admin_name), + action = escape_html(&e.action), + target = escape_html(&target), + board = board_link, + detail = escape_html(&e.detail) + ), ); } @@ -1415,10 +1427,9 @@ pub fn admin_db_health_result_page( .push_str(r#"
  • No repairs were run.
  • "#); } else { for line in &report.repair_summary { - let _ = write!( - repair_summary_html, - r"
  • {line}
  • ", - line = escape_html(line) + crate::templates::append_html( + &mut repair_summary_html, + format_args!(r"
  • {line}
  • ", line = escape_html(line)), ); } } @@ -1429,10 +1440,9 @@ pub fn admin_db_health_result_page( .push_str(r#"
  • No maintenance steps were run.
  • "#); } else { for step in &report.repair_steps { - let _ = write!( - repair_steps_html, - r"
  • {step}
  • ", - step = escape_html(step) + crate::templates::append_html( + &mut repair_steps_html, + format_args!(r"
  • {step}
  • ", step = escape_html(step)), ); } } @@ -1763,7 +1773,7 @@ pub fn admin_ip_history_page( for (post, _) in posts_with_boards { let name = post.name.trim(); if !name.is_empty() && name != "Anonymous" { - seen_names.insert(name.to_owned()); + let _was_new_name = seen_names.insert(name.to_owned()); } if let Some(tripcode) = post @@ -1772,7 +1782,7 @@ pub fn admin_ip_history_page( .map(str::trim) .filter(|value| !value.is_empty()) { - seen_tripcodes.insert(tripcode.to_owned()); + let _was_new_tripcode = seen_tripcodes.insert(tripcode.to_owned()); } } @@ -1837,9 +1847,12 @@ pub fn admin_ip_history_page( pid = post.id, board = escape_html(board_short), ); - let report_form = post.ip_hash.as_deref().map_or_else(String::new, |ip_hash| { - format!( - r#" "#, - csrf = escape_html(csrf_token), - pid = post.id, - tid = post.thread_id, - board = escape_html(board_short), - ip_hash = escape_html(ip_hash), - ) - }); + csrf = escape_html(csrf_token), + pid = post.id, + tid = post.thread_id, + board = escape_html(board_short), + ip_hash = escape_html(post_ip_hash), + ) + }); - let _ = write!( - rows, - r#" + crate::templates::append_html( + &mut rows, + format_args!( + r#" {time} {link}{op} {name} @@ -1875,15 +1889,16 @@ pub fn admin_ip_history_page( {report} {del} "#, - time = fmt_ts_short(post.created_at), - link = thread_link, - op = op_badge, - name = name_html, - tripcode = tripcode_html, - media = media_badge, - body = body_preview, - report = report_form, - del = del_form + time = fmt_ts_short(post.created_at), + link = thread_link, + op = op_badge, + name = name_html, + tripcode = tripcode_html, + media = media_badge, + body = body_preview, + report = report_form, + del = del_form + ), ); } @@ -1924,7 +1939,10 @@ pub fn admin_ip_history_page( let mut pag_base = format!("/admin/ip/{}", escape_html(ip_hash)); if let Some(return_to) = return_to.filter(|value| !value.is_empty()) { let sep = if pag_base.contains('?') { "&" } else { "?" }; - let _ = write!(pag_base, "{sep}return_to={}", urlencoding_simple(return_to)); + crate::templates::append_html( + &mut pag_base, + format_args!("{sep}return_to={}", urlencoding_simple(return_to)), + ); } let pag_html = render_pagination(pagination, &pag_base); diff --git a/src/templates/admin/accounts.rs b/src/templates/admin/accounts.rs index 9fdf58ff..38bf20bf 100644 --- a/src/templates/admin/accounts.rs +++ b/src/templates/admin/accounts.rs @@ -1,7 +1,5 @@ //! Secret-free administrator list and dedicated credential workflows. -use std::fmt::Write as _; - use crate::utils::sanitize::escape_html; /// Render full-privilege account management with mandatory reauthentication. @@ -11,11 +9,14 @@ pub(super) fn render(users: &[(i64, String, i64)], csrf: &str) -> String { for (id, name, created) in users { let name = escape_html(name); let created = escape_html(&crate::templates::fmt_ts(*created)); - let _ = write!( - rows, - "{id}{name}{created}" + crate::templates::append_html( + &mut rows, + format_args!("{id}{name}{created}"), + ); + crate::templates::append_html( + &mut choices, + format_args!(""), ); - let _ = write!(choices, ""); } format!(r#"

    Accounts

    All administrators have full site privileges. Existing passwords and hashes are never displayed. Every change requires your current password; failed checks share the administrator login lockout.

    {rows}
    IDUsernameCreated
    {}{}
    "#, form("create", "Create administrator", "", csrf), diff --git a/src/templates/admin/appearance.rs b/src/templates/admin/appearance.rs index 32e33af2..0aef3de6 100644 --- a/src/templates/admin/appearance.rs +++ b/src/templates/admin/appearance.rs @@ -8,7 +8,6 @@ use crate::theme_builder::{ builder_defaults_for_preset, parse_builder_config, ThemeBuilderConfig, ThemeDensity, ThemeFontFamily, BUILDER_PRESETS, }; -use std::fmt::Write as _; /// Renders the general site-identity settings section. pub(super) fn render_site_settings(view: &AdminPanelViewModel<'_>) -> String { @@ -148,16 +147,18 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { fn render_enabled_theme_options(view: &AdminPanelViewModel<'_>) -> String { let mut enabled_theme_options = String::new(); for theme in view.appearance.themes.iter().filter(|theme| theme.enabled) { - let _ = write!( - enabled_theme_options, - r#""#, - slug = escape_html(&theme.slug), - selected = if theme.slug == view.appearance.default_theme { - " selected" - } else { - "" - }, - label = escape_html(&theme.display_name) + crate::templates::append_html( + &mut enabled_theme_options, + format_args!( + r#""#, + slug = escape_html(&theme.slug), + selected = if theme.slug == view.appearance.default_theme { + " selected" + } else { + "" + }, + label = escape_html(&theme.display_name) + ), ); } enabled_theme_options @@ -192,16 +193,18 @@ fn render_board_appearance_cards(view: &AdminPanelViewModel<'_>) -> String { fn render_preset_options(selected_slug: &str) -> String { let mut out = String::new(); for preset in BUILDER_PRESETS { - let _ = write!( - out, - r#""#, - slug = escape_html(preset.slug), - selected = if preset.slug == selected_slug { - " selected" - } else { - "" - }, - label = escape_html(preset.label), + crate::templates::append_html( + &mut out, + format_args!( + r#""#, + slug = escape_html(preset.slug), + selected = if preset.slug == selected_slug { + " selected" + } else { + "" + }, + label = escape_html(preset.label), + ), ); } out @@ -565,18 +568,20 @@ fn render_builder_preview(config: &ThemeBuilderConfig, slug: &str) -> String { ("success", &config.success_color), ("danger", &config.danger_color), ] { - let _ = write!(preview_style, "--theme-preview-{property}:{value};"); + crate::templates::append_html( + &mut preview_style, + format_args!("--theme-preview-{property}:{value};"), + ); } let (gap, padding) = match config.density { ThemeDensity::Compact => ("0.35rem", "0.45rem"), ThemeDensity::Cozy => ("0.55rem", "0.75rem"), }; - let _ = write!( - preview_style, + crate::templates::append_html(&mut preview_style, format_args!( "--theme-preview-radius:{}px;--theme-preview-font:{};--theme-preview-gap:{gap};--theme-preview-pad:{padding};", config.border_radius_px, config.font_family.css_stack(), - ); + )); format!( r##"
    diff --git a/src/templates/admin/application.rs b/src/templates/admin/application.rs index 418288d2..e79ec030 100644 --- a/src/templates/admin/application.rs +++ b/src/templates/admin/application.rs @@ -1,7 +1,5 @@ //! Compact active/saved/source review for retained live controls. -use std::fmt::Write as _; - use crate::{config::admin::SettingField, utils::sanitize::escape_html}; /// Render separate active, saved and restart state for every retained control. @@ -11,7 +9,7 @@ pub(super) fn render(fields: &Result, String>) -> String { }; let mut rows = String::new(); for f in fields { - let _ = write!(rows, "{}{}{}{}{}{}
    Setting help

    {}

    ", escape_html(f.definition.key), escape_html(f.definition.label), escape_html(&f.active), escape_html(&f.saved), escape_html(&f.next_start), escape_html(&f.source), if f.definition.application == crate::config::admin::ApplicationMode::Live && !f.overridden { "Applies live" } else if f.overridden { "Saved value is overridden on restart" } else if f.pending { "Restart would change the active value" } else { "Active matches persisted/effective state" }, escape_html(f.definition.help)); + crate::templates::append_html(&mut rows, format_args!( "{}{}{}{}{}{}
    Setting help

    {}

    ", escape_html(f.definition.key), escape_html(f.definition.label), escape_html(&f.active), escape_html(&f.saved), escape_html(&f.next_start), escape_html(&f.source), if f.definition.application == crate::config::admin::ApplicationMode::Live && !f.overridden { "Applies live" } else if f.overridden { "Saved value is overridden on restart" } else if f.pending { "Restart would change the active value" } else { "Active matches persisted/effective state" }, escape_html(f.definition.help))); } format!("

    Configuration application state

    Existing live controls are reviewed here alongside the restart controls in each section. Appearance, themes and pruning use the database after seeding. FFmpeg timeout is committed with Media settings in the database. Automatic backups save to settings.toml before updating the scheduler. Startup aliases do not compete with an already configured database value.

    {rows}
    SettingActiveSavedNext restartSourceApplication / help
    ") } diff --git a/src/templates/admin/backups.rs b/src/templates/admin/backups.rs index 2d5e31e3..feaffd4e 100644 --- a/src/templates/admin/backups.rs +++ b/src/templates/admin/backups.rs @@ -1,7 +1,6 @@ //! Full-site and per-board backup sections of the admin panel. use super::{escape_html, format_file_size, render_board_backup_card, AdminPanelViewModel}; -use std::fmt::Write as _; /// Renders the complete backups tab of the admin panel. pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { @@ -88,11 +87,13 @@ fn render_full_backup_rows(view: &AdminPanelViewModel<'_>) -> String { }; let mut board_options = String::new(); for board in &bf.boards { - let _ = write!( - board_options, - r#""#, - short = escape_html(&board.short_name), - name = escape_html(&board.name) + crate::templates::append_html( + &mut board_options, + format_args!( + r#""#, + short = escape_html(&board.short_name), + name = escape_html(&board.name) + ), ); } let board_picker = if bf.boards.is_empty() { @@ -131,11 +132,13 @@ fn render_full_backup_rows(view: &AdminPanelViewModel<'_>) -> String { } else { let mut links = String::new(); for part in &bf.part_filenames { - let _ = write!( - links, - r#"
  • {part}
  • "#, - backup_ref = escape_html(&bf.backup_ref), - part = escape_html(part) + crate::templates::append_html( + &mut links, + format_args!( + r#"
  • {part}
  • "#, + backup_ref = escape_html(&bf.backup_ref), + part = escape_html(part) + ), ); } format!( @@ -182,9 +185,10 @@ fn render_full_backup_rows(view: &AdminPanelViewModel<'_>) -> String { fname = bf.filename ) }; - let _ = write!( - full_backup_rows, - r#" + crate::templates::append_html( + &mut full_backup_rows, + format_args!( + r#"
    {backup_id}
    {scope} · {mode} · {part_summary}
    @@ -241,25 +245,26 @@ fn render_full_backup_rows(view: &AdminPanelViewModel<'_>) -> String {
    "#, - backup_id = escape_html(&bf.backup_id), - backup_ref = escape_html(&bf.backup_ref), - scope = escape_html(&bf.scope), - mode = escape_html(&bf.mode), - part_summary = escape_html(&part_summary), - part_downloads = part_downloads, - indexed_boards_summary = escape_html(&indexed_boards_summary), - tor_backup_summary = escape_html(tor_backup_summary), - size = size_fmt, - modified = escape_html(&bf.modified), - status = status_html, - csrf = escape_html(view.csrf_token), - download_link = download_link, - restore_tor_keys_option = restore_tor_keys_option, - restore_confirm = escape_html(&restore_confirm), - manifest_path = escape_html(&bf.manifest_path), - server_path = escape_html(&bf.server_path), - board_picker = board_picker, - board_help = escape_html(board_help), + backup_id = escape_html(&bf.backup_id), + backup_ref = escape_html(&bf.backup_ref), + scope = escape_html(&bf.scope), + mode = escape_html(&bf.mode), + part_summary = escape_html(&part_summary), + part_downloads = part_downloads, + indexed_boards_summary = escape_html(&indexed_boards_summary), + tor_backup_summary = escape_html(tor_backup_summary), + size = size_fmt, + modified = escape_html(&bf.modified), + status = status_html, + csrf = escape_html(view.csrf_token), + download_link = download_link, + restore_tor_keys_option = restore_tor_keys_option, + restore_confirm = escape_html(&restore_confirm), + manifest_path = escape_html(&bf.manifest_path), + server_path = escape_html(&bf.server_path), + board_picker = board_picker, + board_help = escape_html(board_help), + ), ); } full_backup_rows @@ -376,9 +381,10 @@ fn render_board_backup_rows(view: &AdminPanelViewModel<'_>) -> String { } else { String::new() }; - let _ = write!( - board_backup_rows, - r#" + crate::templates::append_html( + &mut board_backup_rows, + format_args!( + r#"
    {backup_id}
    {mode}
    @@ -416,17 +422,18 @@ fn render_board_backup_rows(view: &AdminPanelViewModel<'_>) -> String {
    "#, - backup_id = escape_html(&bf.backup_id), - backup_ref = escape_html(&bf.backup_ref), - scope = escape_html(&bf.scope), - mode = escape_html(&bf.mode), - size = size_fmt, - modified = escape_html(&bf.modified), - status = status_html, - csrf = escape_html(view.csrf_token), - download_link = download_link, - manifest_path = escape_html(&bf.manifest_path), - server_path = escape_html(&bf.server_path), + backup_id = escape_html(&bf.backup_id), + backup_ref = escape_html(&bf.backup_ref), + scope = escape_html(&bf.scope), + mode = escape_html(&bf.mode), + size = size_fmt, + modified = escape_html(&bf.modified), + status = status_html, + csrf = escape_html(view.csrf_token), + download_link = download_link, + manifest_path = escape_html(&bf.manifest_path), + server_path = escape_html(&bf.server_path), + ), ); } board_backup_rows @@ -441,9 +448,9 @@ fn split_zip_part_size_options(selected_gib: u64) -> String { } else { "" }; - let _ = write!( - options, - r#""# + crate::templates::append_html( + &mut options, + format_args!(r#""#), ); } options diff --git a/src/templates/admin/control_center.rs b/src/templates/admin/control_center.rs index 06cec5b8..bd42e4ae 100644 --- a/src/templates/admin/control_center.rs +++ b/src/templates/admin/control_center.rs @@ -2,7 +2,6 @@ use super::{escape_html, AdminDashboardState, AdminPanelDashboardView, AdminPanelViewModel}; use std::cmp::Reverse; -use std::fmt::Write as _; /// One operational signal displayed by the Control Center. #[derive(Clone, Copy)] @@ -225,9 +224,10 @@ fn render_attention(signals: &DashboardSignals<'_>) -> String { let mut rows = String::new(); for signal in &alerts { - let _ = write!( - rows, - r#"
  • + crate::templates::append_html( + &mut rows, + format_args!( + r#"
  • {label} {value} @@ -236,13 +236,14 @@ fn render_attention(signals: &DashboardSignals<'_>) -> String { {pill} {action}
  • "#, - state = state_class(signal.state), - key = escape_html(signal.key), - label = escape_html(signal.label), - value = escape_html(signal.value), - detail = escape_html(signal.detail), - pill = render_state_pill(signal.state, state_label(signal.state)), - action = section_action_link(signal.open_section, signal.anchor, signal.action), + state = state_class(signal.state), + key = escape_html(signal.key), + label = escape_html(signal.label), + value = escape_html(signal.value), + detail = escape_html(signal.detail), + pill = render_state_pill(signal.state, state_label(signal.state)), + action = section_action_link(signal.open_section, signal.anchor, signal.action), + ), ); } format!( @@ -482,17 +483,19 @@ fn render_task_group( fn render_status_rows(signals: &[DashboardSignal<'_>]) -> String { let mut rows = String::new(); for signal in signals { - let _ = write!( - rows, - r#"
  • + crate::templates::append_html( + &mut rows, + format_args!( + r#"
  • {label}{value} {pill}
  • "#, - state = state_class(signal.state), - key = escape_html(signal.key), - label = escape_html(signal.label), - value = escape_html(signal.value), - pill = render_state_pill(signal.state, state_label(signal.state)), + state = state_class(signal.state), + key = escape_html(signal.key), + label = escape_html(signal.label), + value = escape_html(signal.value), + pill = render_state_pill(signal.state, state_label(signal.state)), + ), ); } format!(r#"
      {rows}
    "#) @@ -505,11 +508,13 @@ fn render_fact_rows(facts: &[(&str, &str)]) -> String { } let mut rows = String::new(); for (label, value) in facts { - let _ = write!( - rows, - r"
    {label}
    {value}
    ", - label = escape_html(label), - value = escape_html(value), + crate::templates::append_html( + &mut rows, + format_args!( + r"
    {label}
    {value}
    ", + label = escape_html(label), + value = escape_html(value), + ), ); } format!(r#"
    {rows}
    "#) @@ -530,11 +535,13 @@ fn render_system_details(view: &AdminPanelViewModel<'_>, signals: &DashboardSign ]; let mut detail_rows = String::new(); for signal in details { - let _ = write!( - detail_rows, - r"
    {label}
    {detail}
    ", - label = escape_html(signal.label), - detail = escape_html(signal.detail), + crate::templates::append_html( + &mut detail_rows, + format_args!( + r"
    {label}
    {detail}
    ", + label = escape_html(signal.label), + detail = escape_html(signal.detail), + ), ); } let technical_facts = render_fact_rows(&[ diff --git a/src/templates/admin/maintenance.rs b/src/templates/admin/maintenance.rs index 9609964c..92050e7c 100644 --- a/src/templates/admin/maintenance.rs +++ b/src/templates/admin/maintenance.rs @@ -1,7 +1,6 @@ //! Media-pipeline and database-maintenance sections of the admin panel. use super::{escape_html, format_file_size, AdminPanelViewModel}; -use std::fmt::Write as _; /// Pre-rendered values interpolated into the maintenance section. struct MaintenanceSectionView<'a> { @@ -69,10 +68,12 @@ old boards to prevent query performance degradation. } else { let mut addresses = String::new(); if let Some(addr) = view.tor_address { - let _ = write!( - addresses, - r#"

    Onion address: {addr}

    "#, - addr = escape_html(addr) + crate::templates::append_html( + &mut addresses, + format_args!( + r#"

    Onion address: {addr}

    "#, + addr = escape_html(addr) + ), ); } addresses @@ -201,23 +202,25 @@ fn render_media_detection_cards(view: &AdminPanelViewModel<'_>) -> String { &pdf_detail, ), ] { - let _ = write!( - cards, - r#"
    + crate::templates::append_html( + &mut cards, + format_args!( + r#"

    {label}

    {status}

    {detail}

    "#, - label = escape_html(label), - pill_class = if ok { - "admin-detection-pill-ok" - } else { - "admin-detection-pill-missing" - }, - status = if ok { "detected" } else { "missing" }, - detail = escape_html(detail), + label = escape_html(label), + pill_class = if ok { + "admin-detection-pill-ok" + } else { + "admin-detection-pill-missing" + }, + status = if ok { "detected" } else { "missing" }, + detail = escape_html(detail), + ), ); } cards diff --git a/src/templates/admin/management.rs b/src/templates/admin/management.rs index f94aa25c..b69077cb 100644 --- a/src/templates/admin/management.rs +++ b/src/templates/admin/management.rs @@ -1,7 +1,5 @@ //! Actionable offline relocation and secret rotation without exposing material. -use std::fmt::Write as _; - use crate::config::admin::management::ManagementState; use crate::utils::sanitize::escape_html; @@ -34,7 +32,7 @@ pub(super) fn render(state: &Result, csrf: &str) -> Str s.uploads_source.as_str(), ), ] { - let _ = write!(paths, "{label}{}{}{}{source}
    Restart required for changes; paths alone do not migrate data.", escape_html(active), escape_html(saved), escape_html(next), source = escape_html(source)); + crate::templates::append_html(&mut paths, format_args!( "{label}{}{}{}{source}
    Restart required for changes; paths alone do not migrate data.", escape_html(active), escape_html(saved), escape_html(next), source = escape_html(source))); } let saved_secret = if s.secret_saved { "configured material withheld" diff --git a/src/templates/admin/moderation.rs b/src/templates/admin/moderation.rs index cb6ea9d0..d658eb59 100644 --- a/src/templates/admin/moderation.rs +++ b/src/templates/admin/moderation.rs @@ -1,7 +1,6 @@ //! Report, appeal, ban, and word-filter sections of the admin panel. use super::{escape_html, fmt_ts, AdminPanelViewModel}; -use std::fmt::Write as _; /// Renders the complete moderation tab of the admin panel. pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { @@ -45,9 +44,10 @@ fn render_ban_rows(view: &AdminPanelViewModel<'_>) -> String { let expires = ban .expires_at .map_or_else(|| "permanent".to_owned(), fmt_ts); - let _ = write!( - ban_rows, - r#" + crate::templates::append_html( + &mut ban_rows, + format_args!( + r#" {}{}{}
    @@ -57,11 +57,12 @@ fn render_ban_rows(view: &AdminPanelViewModel<'_>) -> String {
    "#, - escape_html(ban.ip_hash.get(..16).unwrap_or(&ban.ip_hash)), - escape_html(ban.reason.as_deref().unwrap_or("")), - escape_html(&expires), - csrf = escape_html(view.csrf_token), - id = ban.id + escape_html(ban.ip_hash.get(..16).unwrap_or(&ban.ip_hash)), + escape_html(ban.reason.as_deref().unwrap_or("")), + escape_html(&expires), + csrf = escape_html(view.csrf_token), + id = ban.id + ), ); } ban_rows @@ -71,9 +72,10 @@ fn render_ban_rows(view: &AdminPanelViewModel<'_>) -> String { fn render_filter_rows(view: &AdminPanelViewModel<'_>) -> String { let mut filter_rows = String::new(); for f in view.moderation.filters { - let _ = write!( - filter_rows, - r#" + crate::templates::append_html( + &mut filter_rows, + format_args!( + r#" {}{}
    @@ -83,10 +85,11 @@ fn render_filter_rows(view: &AdminPanelViewModel<'_>) -> String {
    "#, - escape_html(&f.pattern), - escape_html(&f.replacement), - csrf = escape_html(view.csrf_token), - id = f.id + escape_html(&f.pattern), + escape_html(&f.replacement), + csrf = escape_html(view.csrf_token), + id = f.id + ), ); } filter_rows @@ -124,9 +127,10 @@ fn render_report_rows(view: &AdminPanelViewModel<'_>) -> String { ) }, ); - let _ = write!( - report_rows, - r#" + crate::templates::append_html( + &mut report_rows, + format_args!( + r#" /{board}/ No.{pid} {user_info} {preview} @@ -140,15 +144,16 @@ fn render_report_rows(view: &AdminPanelViewModel<'_>) -> String { "#, - board = escape_html(&rc.board_short), - tid = rc.report.thread_id, - pid = rc.report.post_id, - user_info = user_info, - preview = preview, - reason = reason, - age = escape_html(&age), - csrf = escape_html(view.csrf_token), - rid = rc.report.id + board = escape_html(&rc.board_short), + tid = rc.report.thread_id, + pid = rc.report.post_id, + user_info = user_info, + preview = preview, + reason = reason, + age = escape_html(&age), + csrf = escape_html(view.csrf_token), + rid = rc.report.id + ), ); } report_rows @@ -167,9 +172,10 @@ fn render_appeal_rows(csrf_token: &str, appeals: &[crate::models::BanAppeal]) -> let age = fmt_ts(a.created_at); let ip_short = a.ip_hash.get(..16).unwrap_or(&a.ip_hash); let ip_short = escape_html(ip_short); - let _ = write!( - appeal_rows, - r#" + crate::templates::append_html( + &mut appeal_rows, + format_args!( + r#" {ip_short}… {reason} {age} @@ -188,12 +194,13 @@ fn render_appeal_rows(csrf_token: &str, appeals: &[crate::models::BanAppeal]) -> "#, - ip_short = ip_short, - reason = reason, - age = escape_html(&age), - csrf = escape_html(csrf_token), - aid = a.id, - ip_hash = escape_html(&a.ip_hash) + ip_short = ip_short, + reason = reason, + age = escape_html(&age), + csrf = escape_html(csrf_token), + aid = a.id, + ip_hash = escape_html(&a.ip_hash) + ), ); } appeal_rows diff --git a/src/templates/admin/network.rs b/src/templates/admin/network.rs index 1010e446..7fe214d7 100644 --- a/src/templates/admin/network.rs +++ b/src/templates/admin/network.rs @@ -1,7 +1,5 @@ //! Dedicated Network & Security forms with honest startup configuration state. -use std::fmt::Write as _; - use crate::config::admin::{InputKind, SettingField}; use crate::utils::sanitize::escape_html; @@ -135,7 +133,7 @@ fn render_control_groups(fields: &[SettingField], section: &str) -> String { .filter(|f| keys.contains(&f.definition.key)) .map(render_field) .collect::(); - let _ = write!(output, "
    {label}
    {controls}
    "); + crate::templates::append_html(&mut output, format_args!( "
    {label}
    {controls}
    ")); } output } diff --git a/src/templates/admin/search.rs b/src/templates/admin/search.rs index 2b4e0a9c..71bf4dda 100644 --- a/src/templates/admin/search.rs +++ b/src/templates/admin/search.rs @@ -1,7 +1,5 @@ //! Server-rendered settings search, including no-JavaScript operation. -use std::fmt::Write as _; - use crate::config::admin::{application, runtime, NETWORK_SETTINGS}; use crate::utils::sanitize::escape_html; @@ -31,7 +29,7 @@ pub(super) fn render(query: Option<&str>) -> String { "Application state", application::SETTINGS, ))); - let mut count = 0; + let mut count = 0_usize; for (section, label, definitions) in sections { for definition in definitions { let haystack = format!( @@ -40,17 +38,19 @@ pub(super) fn render(query: Option<&str>) -> String { ) .to_lowercase(); if terms.iter().all(|term| haystack.contains(term)) { - count += 1; - let _ = write!( - results, - "
  • {} — {}
  • ", - if section == "configuration-state" { - format!("application-{}", definition.key) - } else { - format!("setting-{}", definition.key) - }, - escape_html(label), - escape_html(definition.label) + count = count.saturating_add(1); + crate::templates::append_html( + &mut results, + format_args!( + "
  • {} — {}
  • ", + if section == "configuration-state" { + format!("application-{}", definition.key) + } else { + format!("setting-{}", definition.key) + }, + escape_html(label), + escape_html(definition.label) + ), ); } } @@ -67,7 +67,7 @@ pub(super) fn render(query: Option<&str>) -> String { ("moderation", "Moderation", "moderation reports appeals bans filters words IP"), ] { let haystack = synonyms.to_lowercase(); - if terms.iter().all(|term| haystack.contains(term)) { count += 1; let _ = write!(results, "
  • {label}
  • "); } + if terms.iter().all(|term| haystack.contains(term)) { count = count.saturating_add(1); crate::templates::append_html(&mut results, format_args!( "
  • {label}
  • ")); } } if count == 0 { results.push_str("
  • No matching runtime settings. Try proxy, Serveo, slow down or upload limit.
  • "); diff --git a/src/templates/admin/site_health.rs b/src/templates/admin/site_health.rs index 68873223..4acb6a0c 100644 --- a/src/templates/admin/site_health.rs +++ b/src/templates/admin/site_health.rs @@ -1,7 +1,6 @@ //! Runtime health and dependency diagnostics for the admin panel. use super::{escape_html, AdminDetectionStatus, AdminPanelViewModel}; -use std::fmt::Write as _; /// Renders the complete site-health section. pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { @@ -138,11 +137,13 @@ fn append_job_rows(rows: &mut String, view: &AdminPanelViewModel<'_>) { /// Appends one escaped label-value health row. fn append_health_row(out: &mut String, label: &str, value: &str) { - let _ = write!( + crate::templates::append_html( out, - r#"
    {label}{value}
    "#, - label = escape_html(label), - value = escape_html(value), + format_args!( + r#"
    {label}{value}
    "#, + label = escape_html(label), + value = escape_html(value), + ), ); } @@ -154,33 +155,39 @@ fn append_health_job_row(out: &mut String, label: &str, value: &str, key: &str, } else { "" }; - let _ = write!( + crate::templates::append_html( out, - r#"
    "#, - label = escape_html(label), - key = escape_html(key), - value = escape_html(value), - csrf = escape_html(csrf_token), - disabled_attr = disabled_attr, + format_args!( + r#"
    "#, + label = escape_html(label), + key = escape_html(key), + value = escape_html(value), + csrf = escape_html(csrf_token), + disabled_attr = disabled_attr, + ), ); return; } if key == "recent_completed_jobs" { - let _ = write!( + crate::templates::append_html( out, - r#"
    "#, - label = escape_html(label), - key = escape_html(key), - value = escape_html(value), + format_args!( + r#"
    "#, + label = escape_html(label), + key = escape_html(key), + value = escape_html(value), + ), ); return; } - let _ = write!( + crate::templates::append_html( out, - r#"
    {label}{value}
    "#, - label = escape_html(label), - key = escape_html(key), - value = escape_html(value), + format_args!( + r#"
    {label}{value}
    "#, + label = escape_html(label), + key = escape_html(key), + value = escape_html(value), + ), ); } diff --git a/src/templates/admin/updates.rs b/src/templates/admin/updates.rs index 7e5da0a2..d5d4af4e 100644 --- a/src/templates/admin/updates.rs +++ b/src/templates/admin/updates.rs @@ -2,7 +2,6 @@ use crate::updates::{Discovery, Status}; use crate::utils::sanitize::escape_html; -use std::fmt::Write as _; /// Render native update status or a clear deployment-managed check-only workflow. pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool) -> String { @@ -35,11 +34,13 @@ pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool .available_release() .is_some_and(|release| release.compatible) }) { - let _ = write!( - controls, - r#"

    Install verified update

    A verified backup is mandatory. RustChan will briefly restart. Failed upgrades restore the previous software, database, configuration and persistent files.

    Approval expires after 10 minutes and can be used once.

    "#, - escape_html(csrf), - escape_html(approval) + crate::templates::append_html( + &mut controls, + format_args!( + r#"

    Install verified update

    A verified backup is mandatory. RustChan will briefly restart. Failed upgrades restore the previous software, database, configuration and persistent files.

    Approval expires after 10 minutes and can be used once.

    "#, + escape_html(csrf), + escape_html(approval) + ), ); } } else { @@ -48,7 +49,10 @@ pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool } else { "Installation is deployment-managed on this system. Install the official release using your deployment tools. Native self-updates require the supported Linux updater service." }; - let _ = write!(controls, "

    {note}

    "); + crate::templates::append_html( + &mut controls, + format_args!("

    {note}

    "), + ); } format!( r#"

    Software Updates

    Stable releases from the official RustChan repository. Update checks and installation details are visible only to administrators.

    Running version
    {}
    Latest stable version
    {latest}
    Last check
    {}
    Verification
    {verification}
    Previous result / current progress
    {}
    {controls}

    If the server restarts, reconnect to Software Updates to see the persisted result. JavaScript reconnects automatically while an installation is active.

    {}
    "#, @@ -78,18 +82,20 @@ fn backup_summary(status: &Status) -> String { } let mut rows = String::new(); for backup in status.backups.iter().take(20) { - let _ = write!( - rows, - "{}{} → {}{}{}", - escape_html(&backup.created_at), - escape_html(&backup.previous_version), - escape_html(&backup.target_version), - backup.size, - if backup.verified { - "Verified pre-upgrade backup" - } else { - "Verification failed" - } + crate::templates::append_html( + &mut rows, + format_args!( + "{}{} → {}{}{}", + escape_html(&backup.created_at), + escape_html(&backup.previous_version), + escape_html(&backup.target_version), + backup.size, + if backup.verified { + "Verified pre-upgrade backup" + } else { + "Verification failed" + } + ), ); } format!("
    {rows}
    CreatedUpgradeBytesVerification
    ") diff --git a/src/templates/board.rs b/src/templates/board.rs index 80569ee7..daf94810 100644 --- a/src/templates/board.rs +++ b/src/templates/board.rs @@ -3,7 +3,6 @@ use crate::models::{Board, Pagination, Post, Thread, ThreadSummary, SEARCH_QUERY_MAX_CHARS}; use crate::utils::sanitize::escape_html; use std::collections::{HashMap, HashSet}; -use std::fmt::Write as _; use super::{ base_layout, base_layout_with_preferences, compress_modal_script, embed_thumb_from_body, @@ -642,7 +641,7 @@ fn board_cards( admin_csrf_token, show_reorder_controls, index == 0, - index + 1 == list.len(), + index.checked_add(1) == Some(list.len()), user_preferences, )); } @@ -747,9 +746,11 @@ pub fn index_page( let mut access_links = String::new(); if let Some(addr) = onion_address { let escaped_addr = escape_html(addr); - let _ = write!( - access_links, - r#"

    {escaped_addr}

    "# + crate::templates::append_html( + &mut access_links, + format_args!( + r#"

    {escaped_addr}

    "# + ), ); } let onion_html = if access_links.is_empty() { @@ -884,17 +885,20 @@ pub fn board_page( let admin_form_csrf = admin_csrf_token.unwrap_or(csrf_token); if let Some(msg) = error { - let _ = write!( - body, - r#"
    ⚠ {}
    "#, - escape_html(msg) + crate::templates::append_html( + &mut body, + format_args!( + r#"
    ⚠ {}
    "#, + escape_html(msg) + ), ); } if is_admin { - let _ = write!( - body, - r#"
    + crate::templates::append_html( + &mut body, + format_args!( + r#"
    ■ ADMIN
    @@ -902,8 +906,9 @@ pub fn board_page(
    "#, - csrf = escape_html(admin_form_csrf), - board = escape_html(&board.short_name) + csrf = escape_html(admin_form_csrf), + board = escape_html(&board.short_name) + ), ); } @@ -917,41 +922,45 @@ pub fn board_page( } else { String::new() }; - let _ = write!( - body, - r#"

    /{short}/ — {name}{access_badge}

    {desc}

    + crate::templates::append_html( + &mut body, + format_args!( + r#"

    /{short}/ — {name}{access_badge}

    {desc}

    {board_banner_html}
    [Index][Catalog]{nav_archive}
    "# + ), ); } if can_post { let show_post_form = error.is_some() || new_thread_prefill.is_some(); - let _ = write!( - body, - r##"
    + crate::templates::append_html( + &mut body, + format_args!( + r##"
    [ Post a New Thread ]
    {}
    "##, - super::forms::new_thread_form( - &board.short_name, - csrf_token, - board, - new_thread_prefill, - &format!("/{}", board.short_name), + super::forms::new_thread_form( + &board.short_name, + csrf_token, + board, + new_thread_prefill, + &format!("/{}", board.short_name), + ), + post_form_class = if show_post_form { + "post-form-wrap is-open" + } else { + "post-form-wrap is-collapsed" + }, + post_form_style = if show_post_form { + "display:block" + } else { + "display:none" + }, ), - post_form_class = if show_post_form { - "post-form-wrap is-open" - } else { - "post-form-wrap is-collapsed" - }, - post_form_style = if show_post_form { - "display:block" - } else { - "display:none" - }, ); } else if board.access_mode.requires_unlock_for_posting() { body.push_str(&render_post_access_gate( @@ -1038,62 +1047,72 @@ fn render_thread_summary( "" }; - let _ = write!( - html, - r#"
    + crate::templates::append_html( + &mut html, + format_args!( + r#"
    "#, - tid = t.id, - op_id = t.op_id.unwrap_or(0) + tid = t.id, + op_id = t.op_id.unwrap_or(0) + ), ); let thread_state_badges = super::thread::render_thread_state_badges(t.sticky, t.locked); if let (Some(_file), Some(thumb)) = (&t.op_file, &t.op_thumb) { - let _ = write!( - html, - r#"
    image{badges}
    "#, - board = escape_html(board_short), - tid = t.id, - th = escape_html(thumb), - badges = thread_state_badges + crate::templates::append_html( + &mut html, + format_args!( + r#"
    image{badges}
    "#, + board = escape_html(board_short), + tid = t.id, + th = escape_html(thumb), + badges = thread_state_badges + ), ); } else if let Some(embed_thumb) = t.op_body.as_deref().and_then(embed_thumb_from_body) { - let _ = write!( - html, - r#"
    video thumbnail{badges}
    "#, - board = escape_html(board_short), - tid = t.id, - src = escape_html(&embed_thumb), - badges = thread_state_badges + crate::templates::append_html( + &mut html, + format_args!( + r#"
    video thumbnail{badges}
    "#, + board = escape_html(board_short), + tid = t.id, + src = escape_html(&embed_thumb), + badges = thread_state_badges + ), ); } - let _ = write!( - html, - r#"