diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6af4082..fa70a26 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,9 +1,12 @@ name: python +# Wheels build on every push and pull request. They are published to PyPI from a +# version tag: `v*` ships the bindings alongside a CLI release, and `py-v*` ships a +# bindings-only release without cutting a new CLI version. on: push: branches: [main] - tags: ["py-v*"] + tags: ["v*", "py-v*"] pull_request: workflow_dispatch: @@ -46,7 +49,9 @@ jobs: publish: name: publish to PyPI - if: startsWith(github.ref, 'refs/tags/py-v') + # Any version tag publishes: `v*` from a CLI release, `py-v*` from a + # bindings-only release. `--skip-existing` makes a re-run harmless. + if: startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/py-v') needs: test runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd3cc5..21d735e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,14 @@ name: release # Build and publish binaries when a version tag is pushed. Each target is built on # a native runner, tested, packaged, and attached to the GitHub Release for the tag. +# Once every target has passed, the crate is published to crates.io. on: push: tags: - "v*" -# The release upload needs permission to write to the repository's releases. +# The release upload needs permission to write to the repository's releases. The +# publish job requests its own id-token permission, since it needs an OIDC token. permissions: contents: write @@ -56,3 +58,32 @@ jobs: uses: softprops/action-gh-release@v2 with: files: decayfmt-${{ matrix.target }}.* + + publish: + name: publish to crates.io + # Only after every target has built and tested, so a failing platform stops the + # publish. A crates.io version can never be reused, unlike a GitHub release. + needs: build + runs-on: ubuntu-latest + permissions: + # Required to request the OIDC token that crates.io exchanges for a + # short-lived publish token. No API token is stored in the repository. + id-token: write + contents: read + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: install rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: authenticate with crates.io + id: auth + uses: rust-lang/crates-io-auth-action@v1 + + - name: publish + # The workspace contains decayfmt-py, which is publish = false and ships to + # PyPI instead, so only the root crate is published here. + run: cargo publish --package decayfmt + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/Cargo.lock b/Cargo.lock index d508441..c72fa73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,17 +188,20 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "decayfmt" -version = "0.1.1" +version = "0.2.0" dependencies = [ "clap", + "fs2", + "hound", "image", "rand", "rayon", + "symphonia", ] [[package]] name = "decayfmt-py" -version = "0.1.0" +version = "0.2.0" dependencies = [ "decayfmt", "pyo3", @@ -224,6 +227,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fax" version = "0.2.7" @@ -249,6 +258,16 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -287,6 +306,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "image" version = "0.25.10" @@ -324,6 +349,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lebe" version = "0.5.3" @@ -336,6 +367,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -356,6 +393,15 @@ dependencies = [ "pxfm", ] +[[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-traits" version = "0.2.19" @@ -551,6 +597,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -569,6 +621,129 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[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-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "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-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", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[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 = "2.0.117" @@ -624,6 +799,28 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index bd051a2..4c85294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "decayfmt" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "A file format where decay is a first-class property. Every open permanently corrupts the file." license = "MIT" @@ -12,12 +12,17 @@ exclude = ["assets/", ".github/"] [dependencies] clap = { version = "4", features = ["derive"] } +fs2 = "0.4" +hound = "3.5" image = { version = "0.25", default-features = false, features = [ "bmp", "dds", "exr", "ff", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp", ] } rand = "0.8" rayon = "1" +symphonia = { version = "0.6", default-features = false, features = [ + "wav", "flac", "mp3", "ogg", "vorbis", "pcm", +] } [profile.release] strip = true diff --git a/README.md b/README.md index 4043969..ebfa3db 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ permanently damages the file on disk, with the amount determined by the filename ![The same image, encoded at four instability values and opened in step, decaying at four speeds at once](assets/decay-grid.gif) -Two file types: +Three file types: - `.idcy` for images (example: `photo.idcy3`) - `.tdcy` for text (example: `note.tdcy7`) +- `.adcy` for audio (example: `clip.adcy5`) `x` is a positive integer in the filename, the instability parameter. Higher `x` means more corruption per open. @@ -140,36 +141,40 @@ and PowerShell 7 are fine with the line above. ### Encode -Turn a source image or text file into a decayfmt file. Encoding does not modify the source -file and applies no corruption. +Turn a source image, text, or audio file into a decayfmt file. Encoding does not modify the +source file and applies no corruption. ``` decayfmt encode --input photo.png --output photo.idcy3 decayfmt encode --input note.txt --output note.tdcy7 +decayfmt encode --input song.mp3 --output song.adcy5 ``` -Both the file type and the instability `x` come from the output name: `idcy` for images and -`tdcy` for text, followed by `x` as a positive integer (`photo.idcy3` is an image at `x=3`). +Both the file type and the instability `x` come from the output name: `idcy` for images, +`tdcy` for text, and `adcy` for audio, followed by `x` as a positive integer +(`photo.idcy3` is an image at `x=3`). Invalid output names are rejected before the file is written. Images are decoded to raw -RGBA; text must be valid UTF-8. +RGBA; text must be valid UTF-8; audio (WAV, FLAC, MP3, OGG) is decoded to raw 16-bit PCM, +so an encoded audio file is much larger than a compressed source. ### Open Open a decayfmt file. This corrupts it in place on disk, then displays the result. Images open in your system's default image viewer. Text prints to the terminal, and when there is no terminal (for example when launched from a file manager) it also -opens in your default text editor. +opens in your default text editor. Audio plays in your default audio player. ``` decayfmt open photo.idcy3 decayfmt open note.tdcy7 +decayfmt open clip.adcy5 ``` `x` is read from the filename, so renaming the file changes how hard the next open hits. ## Python -The same library is available from Python as the `decayfmt` package — same format, +The same library is available from Python as the `decayfmt` package, with the same format, same statistics, same errors as the CLI: ``` @@ -244,11 +249,20 @@ state look different and the corruption sequence cannot be replayed. Corruption substitutes bytes in place and never inserts or deletes, so the file length and the positions of untouched bytes are preserved. At low `x` word lengths and layout largely survive. Spaces are replaced at the same rate as any other byte. +- **Audio:** each 16-bit sample is displaced, with probability `p`, by a random amount + bounded by `x`. A sample is one value across two bytes, not two independent bytes, so + replacing its bytes outright would throw it across the full range and a single such + sample is heard as a click however gentle `x` is. Displacing it instead makes `x` mean + roughly what it means for text and images: `x=1` is a faint grain that deepens over + many opens, `x=10` buries the clip in noise on the first. Length is preserved, so the + clip keeps its duration, sample rate, and channel count however far it rots. ## Guarantees - Corruption is written to disk at open time, before display. A crash or kill after the write does not undo it. +- Opens of the same file are serialised with an advisory lock, so concurrent opens each + cost their own corruption. - Read-only files are rejected with an error, because open must modify the payload before displaying it. - The header is never changed after encoding. Only the payload decays. @@ -259,12 +273,11 @@ state look different and the corruption sequence cannot be replayed. ## Limitations - A backup defeats the decay entirely, and a hex editor can tamper with the file. -- Displaying a file writes the corrupted result to a temporary file for the system viewer. - The most recent one persists until the next open sweeps it, or indefinitely if there is - no next open, so a snapshot of the last-shown state stays recoverable until then. -- Two opens running at the same time can race: both read the same starting state, and the - last write wins, so concurrent opens may cost fewer corruptions than sequential ones. -- v1 supports images and text only. No audio, video, or other binary formats. +- Displaying a file writes the corrupted result to a temporary file for the system + viewer, which needs it to outlive the command. Every decayfmt run sweeps the ones left + behind earlier, so the most recent display survives until the next run, or indefinitely + if there is no next run. +- Images, text, and audio only. No video or other binary formats. ## License diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..798da98 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,73 @@ +# Releasing decayfmt + +Binaries, the GitHub Release, the crates.io publish, and the PyPI wheels are all +automated. A release is a version bump and one tag. + +## What a `v*` tag does + +Pushing a `v*` tag starts two workflows in parallel: + +- `release.yml` builds and tests on five targets (Linux x86_64 + aarch64, macOS + x86_64 + arm64, Windows msvc), attaches the archives to the GitHub Release, and + publishes the crate to crates.io. +- `python.yml` builds and smoke-tests wheels on four platforms, then publishes + them to PyPI. + +Each publish waits for its own build matrix to pass, because neither a crates.io +version nor a PyPI version can be reused once uploaded. The two publishes are +independent, so one can succeed while the other fails; recover by fixing the +cause and releasing a new patch version rather than retrying the same one. + +A `py-v*` tag publishes the Python package on its own, without cutting a new CLI +release. See [python/RELEASE.md](python/RELEASE.md). + +## Releasing a new version + +1. Bump `version` in `Cargo.toml`, `python/Cargo.toml`, and + `python/pyproject.toml`. The two Python versions must match; `smoke.py` + asserts the Cargo one. +2. Tag and push: + + ```bash + git tag v0.2.0 + git push origin v0.2.0 + ``` + +3. Verify: + + ```bash + cargo install decayfmt --force && decayfmt --version + pip install -U decayfmt-py && python python/tests/smoke.py + ``` + +## Publishing setup + +Both registries use trusted publishing: a GitHub OIDC token is exchanged for a +short-lived registry token, so no API tokens are stored in the repository. This is +configured already and is recorded here so it can be rebuilt if it is ever lost. + +**crates.io** (crate → Settings → Trusted Publishing): + +| Field | Value | +| --- | --- | +| Repository owner | `aravpanwar` | +| Repository name | `decayfmt` | +| Workflow filename | `release.yml` | +| Environment name | empty | + +**PyPI** (account → Publishing → pending publisher, until the first upload): + +| Field | Value | +| --- | --- | +| PyPI project name | `decayfmt-py` | +| Owner | `aravpanwar` | +| Repository name | `decayfmt` | +| Workflow name | `python.yml` | +| Environment name | `pypi` | + +The PyPI job also needs a GitHub environment named `pypi` (repo → Settings → +Environments) with no secrets and no protection rules. A required reviewer or +wait timer there would stall the publish. + +A crate must already exist on crates.io before a trusted publisher can be +attached to it, so the very first version of a new crate is published manually. diff --git a/benches/corrupt.rs b/benches/corrupt.rs index b1966e1..d3ac397 100644 --- a/benches/corrupt.rs +++ b/benches/corrupt.rs @@ -18,7 +18,7 @@ const MIN_SCENARIO_SECS: f64 = 1.5; /// Fills `bytes` with a cheap deterministic pattern (xorshift64). Deterministic /// so runs are comparable; not cryptographically strong, which is fine because -/// it never feeds the corruption RNG — `corrupt` seeds its own from OS entropy. +/// it never feeds the corruption RNG; `corrupt` seeds its own from OS entropy. fn fill_pattern(bytes: &mut [u8]) { let mut state = 0x243F_6A88_85A3_08D3u64; for chunk in bytes.chunks_mut(8) { @@ -75,7 +75,7 @@ fn main() { let mut payload = vec![0u8; payload_len]; fill_pattern(&mut payload); println!( - "corruption loop benchmark — payload {} MiB ({} bytes), scenarios until >= {:.1} s each", + "corruption loop benchmark: payload {} MiB ({} bytes), scenarios until >= {:.1} s each", payload_mb, payload_len, MIN_SCENARIO_SECS ); println!("cores reported by rayon: {}", rayon::current_num_threads()); diff --git a/benches/encode.rs b/benches/encode.rs index b7b1576..44b111e 100644 --- a/benches/encode.rs +++ b/benches/encode.rs @@ -77,7 +77,7 @@ fn main() { .collect(); std::fs::write(&text_input, &text).expect("write bench source text"); - println!("encode benchmark — end to end (read + decode + build + write)"); + println!("encode benchmark: end to end (read + decode + build + write)"); bench("image", &image_input, &image_output, image_raw_bytes); bench("text", &text_input, &text_output, text.len()); diff --git a/python/Cargo.toml b/python/Cargo.toml index ef68445..b37de4a 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "decayfmt-py" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Python bindings for decayfmt, the file format where decay is a first-class property." license = "MIT" diff --git a/python/README.md b/python/README.md index 772983f..9bcf1bd 100644 --- a/python/README.md +++ b/python/README.md @@ -2,7 +2,7 @@ A file format where decay is a first-class property: **every open permanently corrupts the file.** -This is the Python binding for the [decayfmt](https://github.com/aravpanwar/decayfmt) format. Install it with `pip install decayfmt-py`; the module you import is `decayfmt`. Encode a clean file once, then let it decay one open at a time. Each open replaces a random slice of the payload with noise and writes the damage back to disk — irreversibly. There is no undo, no cache, and no way to view the file without paying for it. The header is immutable; only the payload decays. +This is the Python binding for the [decayfmt](https://github.com/aravpanwar/decayfmt) format. Install it with `pip install decayfmt-py`; the module you import is `decayfmt`. Encode a clean file once, then let it decay one open at a time. Each open replaces a random slice of the payload with noise and writes it back to disk. A corrupted file cannot be restored from its own contents. The header is immutable; only the payload decays. The binding wraps the same Rust library the CLI uses, so behavior, statistics, and errors are identical. @@ -39,7 +39,7 @@ buf = bytearray(b"some text that will decay") decayfmt.corrupt_in_place(buf, 10.0, "text") ``` -Corruption probability per eligible byte is `p = 1 - exp(-x / 10)`: x = 1 corrupts roughly 9.5% per open, x = 10 roughly 63%. Image corruption touches only the R, G, and B channels — transparency (alpha) is never damaged. Text corruption replaces bytes with printable ASCII and may break UTF-8 sequences, which is the point. +Corruption probability per eligible byte is `p = 1 - exp(-x / 10)`: x = 1 corrupts roughly 9.5% per open, x = 10 roughly 63%. Image corruption touches only the R, G, and B channels, so transparency (alpha) is never damaged. Text corruption replaces bytes with printable ASCII, so at high x it can break UTF-8 sequences. ## API @@ -61,17 +61,17 @@ Corruption probability per eligible byte is `p = 1 - exp(-x / 10)`: x = 1 corrup Failures raise built-in exceptions with the exact message the CLI would print: -- `OSError` — filesystem read/write failures (includes the OS error string). -- `PermissionError` — the target file is read-only; opening must cost a corruption, so it is refused. -- `ValueError` — everything else: wrong magic bytes, unknown version, bad filename convention, invalid UTF-8 at encode, undecodable image, and so on. +- `OSError` for filesystem read/write failures (includes the OS error string). +- `PermissionError` for a read-only target, where the corrupted payload cannot be written back. +- `ValueError` for everything else: wrong magic bytes, unknown version, bad filename convention, invalid UTF-8 at encode, undecodable image, and so on. ## Threading -The GIL is released while corruption, encoding, and file operations run, and large payloads (≥ 1 MiB) are corrupted in parallel across cores. Corruption is always drawn from OS-entropy-seeded generators — never deterministic, never replayable. +The GIL is released while corruption, encoding, and file operations run, and large payloads (≥ 1 MiB) are corrupted in parallel across cores. Corruption is drawn from generators seeded by operating system entropy, so the corruption sequence cannot be replayed. ## Image formats -`encode_file` / `encode_bytes` accept whatever the underlying Rust image library decodes: PNG, JPEG, GIF, WebP, TIFF, QOI, BMP, DDS, EXR, HDR, ICO, PNM, TGA, and farbfeld. Images are stored as raw RGBA, so any format you can decode becomes a decaying image. +`encode_file` / `encode_bytes` accept whatever the underlying Rust image library decodes: PNG, JPEG, GIF, WebP, TIFF, QOI, BMP, DDS, EXR, HDR, ICO, PNM, TGA, and farbfeld. Images are stored as raw RGBA, so any format the library can decode becomes a decaying image. ## License diff --git a/python/RELEASE.md b/python/RELEASE.md index 692a66a..3a83738 100644 --- a/python/RELEASE.md +++ b/python/RELEASE.md @@ -12,7 +12,7 @@ The wheel build + publish is fully automated; a release is a version bump and a - Environment name: `pypi` 3. Create a GitHub environment named `pypi` (repo → Settings → Environments) so the publish job's `environment: pypi` gate is satisfied. No secrets are - needed — publishing uses OpenID Connect. + needed, publishing uses OpenID Connect. ## Releasing a new version @@ -21,16 +21,17 @@ The wheel build + publish is fully automated; a release is a version bump and a 2. Tag and push: ```bash - git tag py-v0.1.0 - git push origin py-v0.1.0 + git tag py-v0.2.0 + git push origin py-v0.2.0 ``` 3. The `python` workflow builds and smoke-tests wheels on five platforms (manylinux x86_64 + aarch64, macOS arm64 + x86_64, Windows msvc), then the `publish` job uploads them to PyPI. - PyPI tags are deliberately separate from CLI release tags (`v*`), so - Python versions and CLI versions can ship independently. + A `v*` tag publishes the wheels too, alongside the CLI release and the + crates.io publish, so a normal release needs only that one tag. Use `py-v*` + when the bindings need a release of their own. 4. Verify: diff --git a/python/examples/example.py b/python/examples/example.py index 5f37434..b21ff85 100644 --- a/python/examples/example.py +++ b/python/examples/example.py @@ -74,7 +74,7 @@ print() # --------------------------------------------------------------------------- -# decay_file: open without display — PERMANENTLY corrupts the file on disk +# decay_file: open without display; corrupts the file on disk # --------------------------------------------------------------------------- kind, dims, file_bytes = decayfmt.decay_file(str(output)) print(f"decay_file: kind={kind!r}, dims={dims}, first 16 bytes = header") @@ -148,7 +148,7 @@ def show_exception(label, fn): lambda: decayfmt.encode_bytes(b"definitely not a png", str(bad_png)), ) -# ValueError: wrong magic bytes — the name fits, the contents are not a decayfmt file +# ValueError: wrong magic bytes; the name fits, the contents are not a decayfmt file not_decayfmt = tmp / "fake.tdcy3" not_decayfmt.write_text("just a text file") show_exception("decay_file(wrong magic)", lambda: decayfmt.decay_file(str(not_decayfmt))) @@ -159,13 +159,13 @@ def show_exception(label, fn): Path(str(photo_out)).rename(mismatched) show_exception("decay_file(type mismatch)", lambda: decayfmt.decay_file(str(mismatched))) -# OSError: filesystem failure — source does not exist +# OSError: filesystem failure; source does not exist show_exception( "encode_file(missing source)", lambda: decayfmt.encode_file(str(tmp / "nope.txt"), str(tmp / "out.tdcy3")), ) -# PermissionError: read-only target is refused (opening must cost a corruption) +# PermissionError: read-only target is refused (corruption cannot be written back) readonly = tmp / "locked.tdcy3" decayfmt.encode_file(str(source), str(readonly)) readonly.chmod(0o444) diff --git a/python/pyproject.toml b/python/pyproject.toml index 17a4267..72d986f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "decayfmt-py" -version = "0.1.0" +version = "0.2.0" description = "A file format where decay is a first-class property. Every open permanently corrupts the file. Python bindings for the decayfmt CLI format." readme = "README.md" requires-python = ">=3.11" diff --git a/python/src/lib.rs b/python/src/lib.rs index 75ac86e..446e6eb 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -1,15 +1,15 @@ //! Python bindings for decayfmt, the file format where decay is a first-class //! property: every open permanently corrupts the file. //! -//! This module wraps the Rust library crate directly — the same corruption, -//! encode, and header routines the CLI uses — and exposes them as plain +//! This module wraps the Rust library crate directly, the same corruption, +//! encode, and header routines the CLI uses, and exposes them as plain //! functions. Errors map to built-in Python exceptions: filesystem failures //! raise `OSError`, a read-only target raises `PermissionError`, and every //! other refusal (bad magic, bad filename, invalid UTF-8, ...) raises //! `ValueError`, each carrying the exact message the CLI would print. use decayfmt_core::error::DecayError; -use decayfmt_core::format::{FileType, Header}; +use decayfmt_core::format::{FileType, Header, AUDIO_BITS_PER_SAMPLE}; use pyo3::exceptions::{PyOSError, PyPermissionError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyByteArray, PyByteArrayMethods, PyBytes, PyBytesMethods}; @@ -33,8 +33,9 @@ fn file_type_from_str(kind: &str) -> PyResult { match kind { "image" => Ok(FileType::Image), "text" => Ok(FileType::Text), + "audio" => Ok(FileType::Audio), other => Err(PyValueError::new_err(format!( - "kind must be \"image\" or \"text\", got {other:?}" + "kind must be \"image\", \"text\", or \"audio\", got {other:?}" ))), } } @@ -87,8 +88,8 @@ fn corrupt_in_place( /// Encodes a source file into a decayfmt file, exactly like the CLI `encode`. /// /// The output name determines the payload type and x: `name.idcy` for -/// images, `name.tdcy` for text. The produced file is clean; corruption -/// only ever happens at open time. +/// images, `name.tdcy` for text, `name.adcy` for audio. The produced file +/// is clean; corruption happens at open time. #[pyfunction] fn encode_file(py: Python<'_>, source_path: &str, output_path: &str) -> PyResult<()> { let source = Path::new(source_path); @@ -100,7 +101,8 @@ fn encode_file(py: Python<'_>, source_path: &str, output_path: &str) -> PyResult /// Encodes in-memory bytes into a decayfmt file. /// /// The bytes are treated as a source file: decoded to raw RGBA for image -/// output names and validated as UTF-8 for text names. Useful for sources +/// output names, validated as UTF-8 for text names, and decoded to raw PCM for +/// audio names. Useful for sources /// that are already in memory (e.g. an image from PIL), or when `encode_file` /// would require a temporary file. #[pyfunction] @@ -113,11 +115,10 @@ fn encode_bytes(py: Python<'_>, source: &Bound<'_, PyBytes>, output_path: &str) /// Opens a decayfmt file: permanently corrupts it on disk, no display. /// -/// This is the CLI's open minus the display step — the file on disk is -/// corrupted and persisted, and the result is returned to Python. **There is -/// no recovery: the previous payload state is gone.** Returns the file type -/// label, the image dimensions (or `None` for text), and the full new file -/// bytes including the untouched header. +/// This is the CLI's open minus the display step: the corrupted payload is +/// written back to the file before returning. Returns the file type label, the +/// image dimensions (`None` for text and audio), and the full new file bytes +/// including the untouched header. #[pyfunction] #[allow(clippy::type_complexity)] fn decay_file(py: Python<'_>, path: &str) -> PyResult<(String, Option<(u32, u32)>, Py)> { @@ -126,7 +127,7 @@ fn decay_file(py: Python<'_>, path: &str) -> PyResult<(String, Option<(u32, u32) .detach(|| decayfmt_core::open::decay_in_place(path)) .map_err(map_err)?; let kind = header.file_type.label().to_string(); - let dimensions = header.dimensions.map(|d| (d.width, d.height)); + let dimensions = header.image_dimensions().map(|d| (d.width, d.height)); let bytes = PyBytes::new(py, &file_bytes).into(); Ok((kind, dimensions, bytes)) } @@ -143,27 +144,30 @@ fn parse_filename(name: &str) -> PyResult<(String, f64)> { } /// Reads the 16-byte decayfmt header from `data` and returns its type label -/// and image dimensions (`None` for text). +/// and image dimensions (`None` for text and audio). #[pyfunction] fn read_header(data: &Bound<'_, PyBytes>) -> PyResult<(String, Option<(u32, u32)>)> { let header = Header::read(data.as_bytes()).map_err(map_err)?; Ok(( header.file_type.label().to_string(), - header.dimensions.map(|d| (d.width, d.height)), + header.image_dimensions().map(|d| (d.width, d.height)), )) } /// Builds a 16-byte decayfmt header. /// -/// `kind="image"` requires both `width` and `height`; `kind="text"` ignores -/// them. Useful together with `encode_bytes`-style workflows that write their -/// own files. +/// `kind="image"` requires both `width` and `height`; `kind="audio"` requires +/// `sample_rate` and `channels`; `kind="text"` ignores them all. Useful together +/// with `encode_bytes`-style workflows that write their own files. #[pyfunction] +#[pyo3(signature = (kind, width=None, height=None, sample_rate=None, channels=None))] fn write_header( py: Python<'_>, kind: &str, width: Option, height: Option, + sample_rate: Option, + channels: Option, ) -> PyResult> { let file_type = file_type_from_str(kind)?; let header = match file_type { @@ -176,6 +180,16 @@ fn write_header( } }, FileType::Text => Header::for_text(), + FileType::Audio => match (sample_rate, channels) { + (Some(sample_rate), Some(channels)) => { + Header::for_audio(sample_rate, channels, AUDIO_BITS_PER_SAMPLE) + } + _ => { + return Err(PyValueError::new_err( + "audio headers require both sample_rate and channels", + )); + } + }, }; Ok(PyBytes::new(py, &header.write()).into()) } diff --git a/python/tests/smoke.py b/python/tests/smoke.py index 2c95046..b30d1a3 100644 --- a/python/tests/smoke.py +++ b/python/tests/smoke.py @@ -12,6 +12,8 @@ import threading import time +from pathlib import Path + import decayfmt @@ -20,8 +22,21 @@ def changed_fraction(before, after): return sum(a != b for a, b in zip(before, after)) / len(before) +def expected_version(): + """Reads the version from python/Cargo.toml, the manifest the wheel is built from. + + Hardcoding it here means every release breaks the smoke test until someone + remembers to edit this file too, so it is read from the manifest instead. + """ + manifest = Path(__file__).resolve().parent.parent / "Cargo.toml" + for line in manifest.read_text(encoding="utf-8").splitlines(): + if line.startswith("version"): + return line.split("=", 1)[1].strip().strip('"') + raise AssertionError(f"no version found in {manifest}") + + def main(): - assert decayfmt.__version__ == "0.1.0", decayfmt.__version__ + assert decayfmt.__version__ == expected_version(), decayfmt.__version__ print("PASS import + __version__") # --- corrupt_bytes: statistical behavior through Python ---------------- @@ -51,7 +66,13 @@ def main(): decayfmt.corrupt_in_place(img, 10.0, "image") for i in range(3, len(img), 4): assert img[i] == 0xAB, f"alpha modified at index {i}" - assert img[0] != 0x00 or img[1] != 0x00 or img[2] != 0x00 + # Checking a single pixel would fail by chance: at x=10 each channel survives + # about 37% of the time, so all three surviving is a roughly 5% flake. Count + # across the whole payload instead, where the expected share is far from zero. + changed = sum( + 1 for i in range(len(img)) if i % 4 != 3 and img[i] != 0x00 + ) + assert changed > len(img) // 4, f"only {changed} rgb bytes were corrupted" print("PASS image alpha channel preserved") # --- filename parsing ---------------------------------------------------- diff --git a/src/corrupt.rs b/src/corrupt.rs index 336a73b..f71f1fa 100644 --- a/src/corrupt.rs +++ b/src/corrupt.rs @@ -1,12 +1,11 @@ -//! Corruption algorithms for image and text payloads. +//! Corruption algorithms for image, text, and audio payloads. //! //! These are pure functions over byte slices. There is no file I/O and no CLI //! logic here; the caller is responsible for reading the payload, calling into //! this module, and writing the result back. The invariant this module upholds is //! that corruption is always stochastic and always drawn from a cryptographically -//! secure generator seeded from operating system entropy, never from a fixed seed. -//! It is never deterministic, because a reproducible corruption sequence could be -//! replayed to reconstruct the original and undo the decay. Large payloads are +//! secure generator seeded from operating system entropy, never from a fixed seed, +//! so the corruption sequence is not reproducible. Large payloads are //! processed in parallel regions, each of which seeds its own OS-entropy //! generator; the per-byte decisions stay independent across region boundaries, //! so the aggregate remains undirected noise. @@ -17,7 +16,7 @@ // lint do not warn about the allow itself. #![allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] -use crate::format::FileType; +use crate::format::{FileType, AUDIO_BYTES_PER_SAMPLE}; use rand::{Rng, RngCore}; /// The divisor in the corruption probability curve p = 1 - exp(-x / DECAY_SCALE). @@ -27,8 +26,7 @@ use rand::{Rng, RngCore}; /// makes every x gentler (more opens are needed to reach the same damage). At the /// current value of 10, x = 1 corrupts roughly 9.5% of eligible bytes per open and /// x = 10 corrupts roughly 63%. Changing this number changes the meaning of every -/// existing filename's x, so it is treated as part of the format's feel, not a -/// free parameter. +/// existing filename's x, so it is fixed rather than configurable. const DECAY_SCALE: f64 = 10.0; /// Number of bytes per pixel in an RGBA image payload. @@ -37,6 +35,13 @@ const RGBA_BYTES_PER_PIXEL: usize = 4; /// Index of the alpha channel within an RGBA pixel. This byte is never corrupted. const ALPHA_INDEX: usize = 3; +/// Largest displacement applied to an audio sample at the most destructive x, as an +/// absolute sample value. Full scale, so as x rises the injected noise grows loud enough +/// to bury the payload rather than merely sitting alongside it. Raising it makes every +/// audio x harsher; lowering it makes audio decay gentler without touching text or +/// images. +const AUDIO_MAX_NOISE: u16 = 32_767; + /// Lowest printable ASCII byte used as a text corruption replacement (space). const PRINTABLE_ASCII_LOW: u8 = 0x20; @@ -79,8 +84,8 @@ fn corruption_probability(x: f64) -> f64 { /// the threshold, giving an effective per-byte probability of t / 65536. That /// deviates from the continuous p by at most 1 / 65536 (about 1.5e-5), which is /// far below what any filename x can distinguish. The cast saturates: p = 1 -/// becomes 65535, so even a saturated threshold leaves a 1-in-65536 byte -/// untouched rather than promising exact certainty. +/// becomes 65535, so even a saturated threshold leaves roughly 1 byte in 65536 +/// untouched. fn probability_threshold(x: f64) -> u16 { (corruption_probability(x) * 65536.0) as u16 } @@ -99,13 +104,16 @@ pub fn corrupt(payload: &mut [u8], file_type: FileType, x: f64) { /// Corrupts the eligible portion of `payload` using the given integer threshold. /// -/// Text corrupts every byte; image corrupts only the R, G, and B channels of -/// whole pixels, so the payload is truncated to a whole number of pixels and any -/// trailing bytes are left untouched. +/// Text and audio corrupt every byte; image corrupts only the R, G, and B channels +/// of whole pixels. Image and audio payloads are truncated to a whole number of +/// pixels or samples, so any trailing partial unit is left untouched. fn corrupt_payload(payload: &mut [u8], file_type: FileType, threshold: u16) { let eligible_len = match file_type { FileType::Text => payload.len(), FileType::Image => payload.len() - payload.len() % RGBA_BYTES_PER_PIXEL, + // Audio corrupts every byte of every whole sample. A trailing partial sample + // is left alone so the payload always ends on a sample boundary. + FileType::Audio => payload.len() - payload.len() % AUDIO_BYTES_PER_SAMPLE, }; if eligible_len == 0 { return; @@ -154,6 +162,7 @@ fn region_work( match file_type { FileType::Text => corrupt_text_chunk(chunk, threshold, &pool, rng), FileType::Image => corrupt_image_chunk(chunk, threshold, &pool), + FileType::Audio => corrupt_audio_chunk(chunk, threshold, &pool), } } } @@ -180,14 +189,61 @@ fn corrupt_image_chunk(chunk: &mut [u8], threshold: u16, pool: &[u16]) { } } +/// Corrupts a 16-bit audio payload chunk by displacing whole samples, each with +/// probability threshold / 65536, by a bounded random amount. +/// +/// A sample is one value split across two bytes, not two independent bytes, so this +/// operates on the sample rather than on its bytes. Replacing the bytes outright would +/// throw the sample anywhere in the full range, and a single such sample is heard as a +/// full-scale click regardless of how gentle x is; replacing only the low byte caps the +/// total possible damage at about 0.8% of full scale, which is inaudible at any x. Both +/// make x mean something different for audio than it does for text and images. +/// +/// Instead a selected sample is displaced by a random offset whose magnitude is bounded +/// by [`audio_noise_amplitude`], which scales with x. Low x is a light graininess, high +/// x swamps the signal, and the result saturates rather than wrapping, so decay never +/// flips a loud sample to the opposite extreme. +/// +/// Substitution preserves length, so the clip keeps its duration, channel count, and +/// sample rate however far it decays. +fn corrupt_audio_chunk(chunk: &mut [u8], threshold: u16, pool: &[u16]) { + let amplitude = audio_noise_amplitude(threshold); + let mut draw = 0; + for sample in chunk.chunks_exact_mut(AUDIO_BYTES_PER_SAMPLE) { + if pool[draw] < threshold { + draw += 1; + // Map the draw onto a signed offset in [-amplitude, amplitude], then + // saturate so a displaced sample clamps at the ends of the range instead + // of wrapping around to the opposite sign. + let span = amplitude.saturating_mul(2).saturating_add(1) as u32; + let offset = (pool[draw] as u32 % span) as i32 - amplitude as i32; + let old = i16::from_le_bytes([sample[0], sample[1]]) as i32; + let new = (old + offset).clamp(i16::MIN as i32, i16::MAX as i32) as i16; + sample.copy_from_slice(&new.to_le_bytes()); + } + draw += 1; + } +} + +/// The maximum distance a corrupted audio sample may move from its original value. +/// +/// Derived from the same threshold that sets how many samples are selected, so a single +/// x controls both how often decay lands and how hard it hits: at low x few samples move +/// and only slightly, at high x most samples move a long way. The two factors compound, +/// so the injected noise sits far below the payload at x = 1 and rises above it by +/// x = 10, which is the same span text and images cover. +fn audio_noise_amplitude(threshold: u16) -> u16 { + let fraction = f64::from(threshold) / f64::from(u16::MAX); + (fraction * f64::from(AUDIO_MAX_NOISE)) as u16 +} + /// Corrupts a text payload chunk by replacing bytes, each independently with /// probability threshold / 65536, with a uniformly random printable ASCII byte /// (0x20 to 0x7E). /// /// Operates on bytes, not Unicode codepoints, so at high x this can split or -/// break multi-byte UTF-8 sequences. That is intended: the display layer is -/// responsible for substituting the Unicode replacement character for whatever -/// is no longer valid. This module only damages bytes. +/// break multi-byte UTF-8 sequences. The display layer substitutes the Unicode +/// replacement character for the resulting invalid sequences. fn corrupt_text_chunk(chunk: &mut [u8], threshold: u16, pool: &[u16], rng: &mut R) { let mut draw = 0; for byte in chunk.iter_mut() { @@ -245,33 +301,106 @@ mod tests { } #[test] - fn text_corruption_fraction_at_x1_is_near_expected() { + fn text_corruption_fraction_matches_probability() { // An all-zero payload makes measurement exact: a corruption replacement is - // always printable ASCII (never zero), so every selected byte visibly - // changes and the changed fraction equals the selection probability. + // always printable ASCII (never zero), so every selected byte visibly changes + // and the changed fraction equals the selection probability. + for (x, expected) in [(1.0, 0.095), (10.0, 0.632)] { + let original = vec![0u8; 100_000]; + let mut payload = original.clone(); + corrupt(&mut payload, FileType::Text, x); + let fraction = changed_count(&original, &payload) as f64 / original.len() as f64; + assert!( + (fraction - expected).abs() < FRACTION_TOLERANCE, + "x={x} text fraction {fraction} not within {FRACTION_TOLERANCE} of {expected}", + ); + } + } + + #[test] + fn audio_displaces_about_the_expected_share_of_samples() { + // Audio displaces whole samples rather than replacing bytes, so the measurement + // is over samples. A displaced sample can land back on its original value, which + // is rare enough at this amplitude to sit inside the tolerance. let original = vec![0u8; 100_000]; let mut payload = original.clone(); - corrupt(&mut payload, FileType::Text, 1.0); - let fraction = changed_count(&original, &payload) as f64 / original.len() as f64; + corrupt(&mut payload, FileType::Audio, 10.0); + + let changed = original + .chunks_exact(AUDIO_BYTES_PER_SAMPLE) + .zip(payload.chunks_exact(AUDIO_BYTES_PER_SAMPLE)) + .filter(|(before, after)| before != after) + .count(); + let total = original.len() / AUDIO_BYTES_PER_SAMPLE; + let fraction = changed as f64 / total as f64; assert!( - (fraction - 0.095).abs() < FRACTION_TOLERANCE, - "x=1 text fraction {} not within {} of 0.095", - fraction, - FRACTION_TOLERANCE + (fraction - 0.632).abs() < FRACTION_TOLERANCE, + "x=10 displaced {fraction} of samples, expected near 0.632", ); } #[test] - fn text_corruption_fraction_at_x10_is_near_expected() { - let original = vec![0u8; 100_000]; - let mut payload = original.clone(); - corrupt(&mut payload, FileType::Text, 10.0); - let fraction = changed_count(&original, &payload) as f64 / original.len() as f64; + fn audio_displacement_is_bounded_and_scales_with_x() { + // No sample may move further than the amplitude x allows, and a higher x must + // move samples further. This is what keeps a single open from turning a quiet + // sample into a full-scale click, which is how x stays comparable to the other + // payload types. + fn mean_shift(x: f64) -> f64 { + // A mid-scale sample everywhere, so displacement in either direction is + // measurable without saturating at the ends of the range. + let original: Vec = (0..50_000).flat_map(|_| 8_000i16.to_le_bytes()).collect(); + let mut payload = original.clone(); + corrupt(&mut payload, FileType::Audio, x); + + let shifts: Vec = original + .chunks_exact(AUDIO_BYTES_PER_SAMPLE) + .zip(payload.chunks_exact(AUDIO_BYTES_PER_SAMPLE)) + .map(|(before, after)| { + let before = i16::from_le_bytes([before[0], before[1]]) as i32; + let after = i16::from_le_bytes([after[0], after[1]]) as i32; + (after - before).abs() + }) + .collect(); + + let bound = i32::from(audio_noise_amplitude(probability_threshold(x))); + assert!( + shifts.iter().all(|shift| *shift <= bound), + "a sample moved further than the amplitude x allows" + ); + shifts.iter().sum::() as f64 / shifts.len() as f64 + } + + let gentle = mean_shift(1.0); + let severe = mean_shift(10.0); assert!( - (fraction - 0.632).abs() < FRACTION_TOLERANCE, - "x=10 text fraction {} not within {} of 0.632", - fraction, - FRACTION_TOLERANCE + severe > gentle * 4.0, + "x=10 mean shift {severe} was not well above x=1 mean shift {gentle}" + ); + } + + #[test] + fn audio_corruption_preserves_length() { + // Length preservation is what keeps a decayed clip at its original duration, + // channel count, and sample rate however far it rots. + let original = vec![7u8; 4096]; + let mut payload = original.clone(); + corrupt(&mut payload, FileType::Audio, 10.0); + assert_eq!( + payload.len(), + original.len(), + "audio corruption must never change the payload length" + ); + } + + #[test] + fn audio_trailing_partial_sample_is_untouched() { + // An odd-length payload cannot end on a 16-bit sample boundary. The trailing + // byte is left alone so the payload always ends on a whole sample. + let mut payload = vec![0u8; 8193]; + corrupt(&mut payload, FileType::Audio, 10.0); + assert_eq!( + payload[8192], 0, + "the trailing partial sample must not be corrupted" ); } @@ -313,7 +442,7 @@ mod tests { // high x many times, and confirm every alpha byte survives untouched. const PIXELS: usize = 2_000; const ALPHA_SENTINEL: u8 = 0xAB; - const RUNS: usize = 1_000; + const RUNS: usize = 20; for _ in 0..RUNS { let mut payload = Vec::with_capacity(PIXELS * RGBA_BYTES_PER_PIXEL); @@ -392,26 +521,6 @@ mod tests { } } - #[test] - fn large_text_fraction_near_expected() { - // 8 MiB all-zero payload spans multiple parallel regions; the measured - // fraction must still match the continuous probability within tolerance. - for (x, expected) in [(1.0, 0.095), (10.0, 0.632)] { - let original = vec![0u8; 8 * 1024 * 1024]; - let mut payload = original.clone(); - corrupt(&mut payload, FileType::Text, x); - let fraction = changed_count(&original, &payload) as f64 / original.len() as f64; - assert!( - (fraction - expected).abs() < FRACTION_TOLERANCE, - "x={} text fraction {} not within {} of {}", - x, - fraction, - FRACTION_TOLERANCE, - expected - ); - } - } - #[test] fn large_image_rgb_fraction_near_expected() { // 8 MiB RGBA payload forces the parallel path; only R, G, B channels diff --git a/src/encode.rs b/src/encode.rs index b30d431..99d052e 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -1,13 +1,13 @@ //! Encode a source file into a decayfmt file. //! -//! This module reads a source image or text file, builds the fixed header via +//! This module reads a source image, text, or audio file, builds the fixed header via //! format.rs, and writes the header followed by the raw, uncorrupted payload. The //! invariant it upholds is that encoding never corrupts: a freshly encoded file is //! clean. Corruption only ever happens at open time, in open.rs. All file I/O for //! the encode flow lives here. use crate::error::DecayError; -use crate::format::{FileType, Header}; +use crate::format::{FileType, Header, AUDIO_BITS_PER_SAMPLE}; use std::fs; use std::io::Write; use std::path::Path; @@ -28,6 +28,120 @@ pub fn decode_image(source_bytes: &[u8], input: &Path) -> Result<(u32, u32, Vec< Ok((width, height, rgba.into_raw())) } +/// Decodes a source audio file into its playback parameters and a raw PCM payload. +/// +/// symphonia probes the container (WAV, FLAC, MP3, OGG) and decodes the first audio +/// track to interleaved signed 16-bit little-endian samples, which is what the decayfmt +/// payload stores. This mirrors how images are reduced to raw RGBA: the container is +/// stripped so corruption operates on samples rather than on compressed frames, where +/// damaging a single byte would break the codec instead of degrading the sound. +/// +/// The sample rate and channel count are returned alongside so the header can record +/// them; the payload is sample data only and does not describe itself. +pub fn decode_audio(source_bytes: &[u8], input: &Path) -> Result<(u32, u8, Vec), DecayError> { + use symphonia::core::codecs::audio::AudioDecoderOptions; + use symphonia::core::formats::probe::Hint; + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + + let decode_error = |message: String| DecayError::AudioDecode { + context: format!("encode: decode audio '{}': {}", input.display(), message), + }; + + // symphonia reads from a seekable stream; the source is already in memory. + let stream = MediaSourceStream::new( + Box::new(std::io::Cursor::new(source_bytes.to_vec())), + Default::default(), + ); + + // The extension is a hint only. If it is wrong or absent, symphonia still probes + // the container by its magic bytes. + let mut hint = Hint::new(); + if let Some(extension) = input.extension().and_then(|raw| raw.to_str()) { + hint.with_extension(extension); + } + + let mut format = symphonia::default::get_probe() + .probe( + &hint, + stream, + FormatOptions::default(), + MetadataOptions::default(), + ) + .map_err(|error| decode_error(error.to_string()))?; + + let track = format + .default_track(symphonia::core::formats::TrackType::Audio) + .ok_or_else(|| decode_error("no audio track found".to_string()))?; + let track_id = track.id; + let codec_params = track + .codec_params + .as_ref() + .and_then(|params| params.audio()) + .ok_or_else(|| decode_error("track has no audio parameters".to_string()))?; + + let mut decoder = symphonia::default::get_codecs() + .make_audio_decoder(codec_params, &AudioDecoderOptions::default()) + .map_err(|error| decode_error(error.to_string()))?; + + let mut sample_rate = codec_params.sample_rate.unwrap_or(0); + let mut channels = codec_params + .channels + .as_ref() + .map(|channels| channels.count()) + .unwrap_or(0); + let mut payload: Vec = Vec::new(); + // copy_bytes_to_vec_interleaved_as resizes its destination to exactly the packet it + // is given, so each packet is converted into this scratch buffer and then appended. + // Writing straight into the payload would overwrite the packets already decoded. + let mut packet_bytes: Vec = Vec::new(); + + // Decode every packet of the chosen track, interleaving samples as they arrive. + // An unsupported or corrupt packet ends decoding rather than being skipped, so a + // truncated clip is never silently encoded as if it were whole. + while let Some(packet) = format + .next_packet() + .map_err(|error| decode_error(error.to_string()))? + { + if packet.track_id != track_id { + continue; + } + let decoded = decoder + .decode(&packet) + .map_err(|error| decode_error(error.to_string()))?; + + let spec = decoded.spec(); + if sample_rate == 0 { + sample_rate = spec.rate(); + } + if channels == 0 { + channels = spec.channels().count(); + } + + // Append this packet's frames as interleaved signed 16-bit little-endian bytes, + // converting from whatever sample format the codec produced. + decoded.copy_bytes_to_vec_interleaved_as::(&mut packet_bytes); + payload.extend_from_slice(&packet_bytes); + } + + if sample_rate == 0 || channels == 0 { + return Err(decode_error( + "missing sample rate or channel count".to_string(), + )); + } + if payload.is_empty() { + return Err(decode_error("no audio samples decoded".to_string())); + } + let channels = u8::try_from(channels).map_err(|_| { + decode_error(format!( + "{channels} channels exceeds the 255 the header holds" + )) + })?; + + Ok((sample_rate, channels, payload)) +} + /// Produces a raw text payload from source bytes, requiring valid UTF-8. /// /// The payload is stored as raw UTF-8 bytes exactly as read. Invalid UTF-8 is @@ -105,6 +219,13 @@ fn build_and_write( (Header::for_image(width, height), payload) } FileType::Text => (Header::for_text(), text_payload(source_bytes)?), + FileType::Audio => { + let (sample_rate, channels, payload) = decode_audio(&source_bytes, source_path)?; + ( + Header::for_audio(sample_rate, channels, AUDIO_BITS_PER_SAMPLE), + payload, + ) + } }; write_decayfmt(output, header, &payload) @@ -176,7 +297,7 @@ mod tests { let header = Header::read(&written).expect("encoded header must parse"); assert_eq!( - header.dimensions, + header.image_dimensions(), Some(ImageDimensions { width, height }), "header must record the source image dimensions" ); diff --git a/src/error.rs b/src/error.rs index 1360f8c..f9fa062 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,32 +15,31 @@ const EXPECTED_MAGIC: &[u8; 4] = b"DCYF"; /// The complete set of failures decayfmt can produce. /// -/// The full contract of how decayfmt fails lives in one place: every error any -/// module returns is a variant here, each naming the component, operation, and -/// condition that failed. +/// Every error any module returns is a variant here, each naming the component, +/// operation, and condition that failed. #[derive(Debug)] pub enum DecayError { /// The file did not start with the magic bytes DCYF. The file is not a /// decayfmt file and must not be parsed any further. WrongMagic { found: [u8; 4] }, - /// The version byte is not a version this build understands. We never guess - /// at forward compatibility; an unknown version is a hard refusal. + /// The version byte is not a version this build understands. An unknown version + /// is refused; no forward compatibility is attempted. UnsupportedVersion { found: u8 }, - /// The file_type byte is neither image (0x01) nor text (0x02). + /// The file_type byte is not image (0x01), text (0x02), or audio (0x03). UnsupportedFileType { found: u8 }, /// The filename's extension prefix and the header disagree about the payload - /// type, for example an image file renamed to a `.tdcy` name. We refuse - /// rather than trust one source over the other. + /// type, for example an image file renamed to a `.tdcy` name. The type cannot + /// be determined, so the open is refused. MismatchedFileType { extension_kind: &'static str, header_kind: &'static str, }, /// The target file is read-only. Corruption cannot be written, so the file is - /// not displayed. Opening must cost a corruption; a free read breaks the contract. + /// not displayed. ReadOnly { path: String }, /// The buffer is shorter than the fixed 16-byte header, so no valid header @@ -58,7 +57,7 @@ pub enum DecayError { XNotPositive { value: f64 }, /// The instability value x in the filename was a run of digits too large to fit - /// a u32. There is an x; it is simply out of the supported range. + /// a u32. XOutOfRange { value: String }, /// A filesystem read or write failed. The context names the component and the @@ -73,8 +72,17 @@ pub enum DecayError { /// error type stays free of any image-crate dependency. ImageDecode { context: String }, - /// The output filename for encode had an extension that is neither an image - /// (idcy) nor a text (tdcy) decayfmt extension, so the file type is unknown. + /// The source given to encode could not be decoded as a supported audio file. + /// The context carries the underlying decoder message, kept as a string so this + /// error type stays free of any audio-crate dependency. + AudioDecode { context: String }, + + /// On open, the corrupted audio could not be re-encoded for playback. The context + /// carries the underlying encoder message as a string. + AudioEncode { context: String }, + + /// The output filename for encode had an extension that is not an image (idcy), + /// text (tdcy), or audio (adcy) decayfmt extension, so the file type is unknown. UnrecognizedExtension { extension: String }, /// On open, the image payload length did not match the width and height in the @@ -102,7 +110,7 @@ impl fmt::Display for DecayError { ), DecayError::UnsupportedFileType { found } => write!( f, - "format: file_type check failed: 0x{:02x} is neither image (0x01) nor text (0x02).", + "format: file_type check failed: 0x{:02x} is not image (0x01), text (0x02), or audio (0x03).", found ), DecayError::MismatchedFileType { @@ -145,9 +153,11 @@ impl fmt::Display for DecayError { ), DecayError::Io { context, source } => write!(f, "{}: {}", context, source), DecayError::ImageDecode { context } => write!(f, "{}", context), + DecayError::AudioDecode { context } => write!(f, "{}", context), + DecayError::AudioEncode { context } => write!(f, "{}", context), DecayError::UnrecognizedExtension { extension } => write!( f, - "encode: output extension '{}' is neither an image (idcy) nor a text (tdcy) decayfmt extension.", + "encode: output extension '{}' is not an image (idcy), text (tdcy), or audio (adcy) decayfmt extension.", extension ), DecayError::PayloadSizeMismatch { expected, found } => write!( diff --git a/src/format.rs b/src/format.rs index e634947..39e7287 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,8 +1,9 @@ //! The decayfmt format definition: the binary header and the filename convention. //! //! This module owns two pieces of format metadata: the fixed 16-byte header (magic, -//! version, file type, and image dimensions) and the filename convention that carries -//! the payload type and the instability value x (`name.idcy` / `name.tdcy`). It +//! version, file type, and a per-type metadata region) and the filename convention that +//! carries the payload type and the instability value x (`name.idcy` / +//! `name.tdcy` / `name.adcy`). It //! knows nothing about corruption, file I/O, or the CLI. The header is written exactly //! once at encode time and never mutated afterward; only the payload that follows it //! ever changes. The invariant this module upholds is that a buffer is only accepted @@ -17,7 +18,11 @@ pub const MAGIC: [u8; 4] = *b"DCYF"; /// The format version this build reads and writes. An unknown version is refused, /// never interpreted, because the meaning of later versions is not knowable here. -pub const VERSION: u8 = 0x01; +/// +/// Version 0x02 added the audio payload type. Files written by earlier builds carry +/// version 0x01 and are refused rather than reinterpreted: the metadata region is laid +/// out per payload type and the v1 layout differs. +pub const VERSION: u8 = 0x02; /// file_type byte for an image payload (raw RGBA pixels). pub const FILE_TYPE_IMAGE: u8 = 0x01; @@ -25,26 +30,55 @@ pub const FILE_TYPE_IMAGE: u8 = 0x01; /// file_type byte for a text payload (raw UTF-8 bytes). pub const FILE_TYPE_TEXT: u8 = 0x02; +/// file_type byte for an audio payload (raw interleaved PCM samples). +pub const FILE_TYPE_AUDIO: u8 = 0x03; + /// Filename extension prefix that precedes x for an image, for example `idcy3`. pub const IMAGE_EXTENSION_PREFIX: &str = "idcy"; /// Filename extension prefix that precedes x for text, for example `tdcy7`. pub const TEXT_EXTENSION_PREFIX: &str = "tdcy"; +/// Filename extension prefix that precedes x for audio, for example `adcy5`. +pub const AUDIO_EXTENSION_PREFIX: &str = "adcy"; + +/// Bits per sample of an audio payload. Sources are decoded to signed 16-bit +/// little-endian PCM regardless of their original depth, so every audio payload this +/// build writes carries this value. It is stored in the header rather than assumed so +/// a reader can reject a payload it would otherwise misinterpret. +pub const AUDIO_BITS_PER_SAMPLE: u8 = 16; + +/// Bytes per audio sample, derived from [`AUDIO_BITS_PER_SAMPLE`]. +pub const AUDIO_BYTES_PER_SAMPLE: usize = (AUDIO_BITS_PER_SAMPLE as usize) / 8; + /// Byte offset of the 4-byte little-endian image width within the header. const WIDTH_OFFSET: usize = 6; /// Byte offset of the 4-byte little-endian image height within the header. const HEIGHT_OFFSET: usize = 10; +/// Byte offset of the 4-byte little-endian audio sample rate in hertz. +const SAMPLE_RATE_OFFSET: usize = 6; + +/// Byte offset of the 1-byte audio channel count. +const CHANNELS_OFFSET: usize = 10; + +/// Byte offset of the 1-byte audio bits-per-sample value. +const BITS_PER_SAMPLE_OFFSET: usize = 11; + /// Byte offset of the reserved region within the header. const RESERVED_OFFSET: usize = 14; -/// Number of reserved bytes after the dimensions. Zero-filled on write, ignored on read. +/// Number of reserved bytes after the per-type metadata. Zero-filled on write, +/// ignored on read. const RESERVED_LEN: usize = 2; /// Total size of the fixed header: 4 (magic) + 1 (version) + 1 (file_type) -/// + 4 (width) + 4 (height) + 2 (reserved). +/// + 8 (per-type metadata) + 2 (reserved). +/// +/// Bytes 6..14 are a per-type metadata region, interpreted according to the file_type +/// byte that precedes it: image stores width and height, audio stores sample rate, +/// channel count, and bits per sample, and text leaves the whole region zero. pub const HEADER_SIZE: usize = RESERVED_OFFSET + RESERVED_LEN; /// Which kind of payload follows the header. @@ -52,6 +86,7 @@ pub const HEADER_SIZE: usize = RESERVED_OFFSET + RESERVED_LEN; pub enum FileType { Image, Text, + Audio, } impl FileType { @@ -60,6 +95,7 @@ impl FileType { match self { FileType::Image => FILE_TYPE_IMAGE, FileType::Text => FILE_TYPE_TEXT, + FileType::Audio => FILE_TYPE_AUDIO, } } @@ -69,6 +105,7 @@ impl FileType { match byte { FILE_TYPE_IMAGE => Ok(FileType::Image), FILE_TYPE_TEXT => Ok(FileType::Text), + FILE_TYPE_AUDIO => Ok(FileType::Audio), other => Err(DecayError::UnsupportedFileType { found: other }), } } @@ -79,13 +116,15 @@ impl FileType { match self { FileType::Image => "image", FileType::Text => "text", + FileType::Audio => "audio", } } } /// Parses the decayfmt filename convention into the payload type and instability x. /// -/// The convention is `name.idcy` for images and `name.tdcy` for text, where x +/// The convention is `name.idcy` for images, `name.tdcy` for text, and +/// `name.adcy` for audio, where x /// is a positive integer. The payload type comes from the prefix and x from the /// integer suffix. Both encode (to validate its output name) and open (to read x and /// cross-check the type against the header) go through here, so the naming rule lives @@ -99,6 +138,8 @@ pub fn parse_filename(path: &Path) -> Result<(FileType, f64), DecayError> { (FileType::Image, rest) } else if let Some(rest) = extension.strip_prefix(TEXT_EXTENSION_PREFIX) { (FileType::Text, rest) + } else if let Some(rest) = extension.strip_prefix(AUDIO_EXTENSION_PREFIX) { + (FileType::Audio, rest) } else { return Err(DecayError::UnrecognizedExtension { extension: extension.to_string(), @@ -128,15 +169,37 @@ pub struct ImageDimensions { pub height: u32, } -/// The parsed, validated header of a decayfmt file. It carries the payload type and, -/// for images, the pixel dimensions needed to interpret the raw RGBA payload. Magic -/// and version are validated on read and not stored, because they are fixed for a -/// given build. +/// The playback parameters of an audio payload. Stored in the header so the flat +/// interleaved PCM payload can be turned back into a playable clip when the file is +/// opened. The payload is always signed 16-bit little-endian PCM, so `bits_per_sample` +/// records the format rather than selecting between several. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AudioSpec { + pub sample_rate: u32, + pub channels: u8, + pub bits_per_sample: u8, +} + +/// The per-type metadata carried in header bytes 6..14. +/// +/// The region is interpreted according to the file_type byte that precedes it, so each +/// payload type names exactly the fields it needs and text carries none, so the +/// file_type byte fully determines the layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Metadata { + Image(ImageDimensions), + Text, + Audio(AudioSpec), +} + +/// The parsed, validated header of a decayfmt file. It carries the payload type and +/// the per-type metadata needed to interpret the raw payload that follows. Magic and +/// version are validated on read and not stored, because they are fixed for a given +/// build. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Header { pub file_type: FileType, - /// Present only for images; None for text, which has no dimensions. - pub dimensions: Option, + pub metadata: Metadata, } /// Reads a little-endian u32 from `buffer` at `offset`. The caller must have already @@ -155,44 +218,84 @@ impl Header { pub fn for_image(width: u32, height: u32) -> Header { Header { file_type: FileType::Image, - dimensions: Some(ImageDimensions { width, height }), + metadata: Metadata::Image(ImageDimensions { width, height }), } } - /// Builds a header for a text payload, which carries no dimensions. + /// Builds a header for a text payload, which carries no metadata. pub fn for_text() -> Header { Header { file_type: FileType::Text, - dimensions: None, + metadata: Metadata::Text, + } + } + + /// Builds a header for an audio payload with the given playback parameters. + pub fn for_audio(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Header { + Header { + file_type: FileType::Audio, + metadata: Metadata::Audio(AudioSpec { + sample_rate, + channels, + bits_per_sample, + }), + } + } + + /// Returns the image dimensions when this header describes an image. + pub fn image_dimensions(&self) -> Option { + match self.metadata { + Metadata::Image(dimensions) => Some(dimensions), + _ => None, + } + } + + /// Returns the audio spec when this header describes an audio payload. + pub fn audio_spec(&self) -> Option { + match self.metadata { + Metadata::Audio(spec) => Some(spec), + _ => None, } } /// Serializes the header to its fixed 16-byte on-disk form. /// - /// Upholds the invariant that the reserved bytes are always zero on write. Image - /// dimensions are written as two little-endian u32 values; for text those bytes - /// stay zero. The header produced here is written once and never rewritten. + /// Upholds the invariant that the reserved bytes are always zero on write. The + /// per-type metadata region is written according to the payload type: image + /// dimensions as two little-endian u32 values, audio as a little-endian u32 sample + /// rate followed by two single-byte fields, and text leaves the region zero. The + /// header produced here is written once and never rewritten. pub fn write(&self) -> [u8; HEADER_SIZE] { let mut bytes = [0u8; HEADER_SIZE]; bytes[0..4].copy_from_slice(&MAGIC); bytes[4] = VERSION; bytes[5] = self.file_type.to_byte(); - if let Some(dimensions) = self.dimensions { - bytes[WIDTH_OFFSET..WIDTH_OFFSET + 4].copy_from_slice(&dimensions.width.to_le_bytes()); - bytes[HEIGHT_OFFSET..HEIGHT_OFFSET + 4] - .copy_from_slice(&dimensions.height.to_le_bytes()); + match self.metadata { + Metadata::Image(dimensions) => { + bytes[WIDTH_OFFSET..WIDTH_OFFSET + 4] + .copy_from_slice(&dimensions.width.to_le_bytes()); + bytes[HEIGHT_OFFSET..HEIGHT_OFFSET + 4] + .copy_from_slice(&dimensions.height.to_le_bytes()); + } + Metadata::Audio(spec) => { + bytes[SAMPLE_RATE_OFFSET..SAMPLE_RATE_OFFSET + 4] + .copy_from_slice(&spec.sample_rate.to_le_bytes()); + bytes[CHANNELS_OFFSET] = spec.channels; + bytes[BITS_PER_SAMPLE_OFFSET] = spec.bits_per_sample; + } + // For text the metadata bytes stay zero. + Metadata::Text => {} } - // For text the dimension bytes stay zero, and the reserved bytes at - // bytes[RESERVED_OFFSET..] are always left zero. + // The reserved bytes at bytes[RESERVED_OFFSET..] are always left zero. bytes } /// Parses and validates a header from the start of a buffer. /// /// Upholds the invariant that a header is only accepted if its magic and version - /// match this build exactly. Image dimensions are read from the header; for text - /// they are absent. The trailing reserved bytes are ignored. Returns a typed - /// error for every way the buffer can fail to be a header this build understands. + /// match this build exactly. The per-type metadata region is read according to the + /// file type. The trailing reserved bytes are ignored. Returns a typed error for + /// every way the buffer can fail to be a header this build understands. pub fn read(buffer: &[u8]) -> Result { if buffer.len() < HEADER_SIZE { return Err(DecayError::PayloadTooSmall { @@ -213,18 +316,23 @@ impl Header { } let file_type = FileType::from_byte(buffer[5])?; - let dimensions = match file_type { - FileType::Image => Some(ImageDimensions { + let metadata = match file_type { + FileType::Image => Metadata::Image(ImageDimensions { width: read_u32_le(buffer, WIDTH_OFFSET), height: read_u32_le(buffer, HEIGHT_OFFSET), }), - FileType::Text => None, + FileType::Text => Metadata::Text, + FileType::Audio => Metadata::Audio(AudioSpec { + sample_rate: read_u32_le(buffer, SAMPLE_RATE_OFFSET), + channels: buffer[CHANNELS_OFFSET], + bits_per_sample: buffer[BITS_PER_SAMPLE_OFFSET], + }), }; // The reserved bytes at buffer[RESERVED_OFFSET..HEADER_SIZE] are ignored. Ok(Header { file_type, - dimensions, + metadata, }) } } @@ -239,27 +347,52 @@ mod tests { } #[test] - fn image_header_round_trips_with_dimensions() { - let original = Header::for_image(640, 480); - let bytes = original.write(); - let parsed = Header::read(&bytes).expect("valid image header must parse"); - assert_eq!(parsed, original, "image round-trip changed the header"); + fn headers_round_trip_with_their_own_metadata() { + // write -> read is identity for each payload type, and the accessors return + // metadata only for the type that carries it. The file_type byte is what + // decides how the shared metadata region is read. + let image = Header::for_image(640, 480); + let text = Header::for_text(); + let audio = Header::for_audio(44_100, 2, AUDIO_BITS_PER_SAMPLE); + + for original in [image, text, audio] { + let parsed = Header::read(&original.write()).expect("valid header must parse"); + assert_eq!(parsed, original, "round-trip changed the header"); + } + + let image = Header::read(&image.write()).expect("image header must parse"); assert_eq!( - parsed.dimensions, + image.image_dimensions(), Some(ImageDimensions { width: 640, height: 480 }) ); - } + assert!( + image.audio_spec().is_none(), + "an image carries no audio spec" + ); - #[test] - fn text_header_round_trips_without_dimensions() { - let original = Header::for_text(); - let bytes = original.write(); - let parsed = Header::read(&bytes).expect("valid text header must parse"); - assert_eq!(parsed, original, "text round-trip changed the header"); - assert_eq!(parsed.dimensions, None, "text headers carry no dimensions"); + let text = Header::read(&text.write()).expect("text header must parse"); + assert!( + text.image_dimensions().is_none(), + "text carries no dimensions" + ); + assert!(text.audio_spec().is_none(), "text carries no audio spec"); + + let audio = Header::read(&audio.write()).expect("audio header must parse"); + assert_eq!( + audio.audio_spec(), + Some(AudioSpec { + sample_rate: 44_100, + channels: 2, + bits_per_sample: AUDIO_BITS_PER_SAMPLE, + }) + ); + assert!( + audio.image_dimensions().is_none(), + "audio carries no image dimensions" + ); } #[test] @@ -291,7 +424,7 @@ mod tests { fn magic_and_version_bytes_are_exact() { let bytes = Header::for_image(2, 2).write(); assert_eq!(&bytes[0..4], b"DCYF", "magic bytes must be DCYF"); - assert_eq!(bytes[4], 0x01, "version byte must be 0x01"); + assert_eq!(bytes[4], 0x02, "version byte must be 0x02"); assert_eq!(bytes[5], FILE_TYPE_IMAGE, "file_type byte must be image"); } @@ -306,15 +439,35 @@ mod tests { } #[test] - fn wrong_version_is_refused() { - let mut bytes = valid_image_header(); - bytes[4] = 0x02; - match Header::read(&bytes) { - Err(DecayError::UnsupportedVersion { found }) => assert_eq!(found, 0x02), - other => panic!("expected UnsupportedVersion, got {:?}", other), + fn unsupported_versions_are_refused() { + // Both a future version and the v1 layout are refused rather than + // reinterpreted: v1 laid out the metadata region differently, so a file from + // an older build cannot be read with the current layout. + for version in [0x01, 0x03] { + let mut bytes = valid_image_header(); + bytes[4] = version; + match Header::read(&bytes) { + Err(DecayError::UnsupportedVersion { found }) => assert_eq!(found, version), + other => panic!("expected UnsupportedVersion for 0x{version:02x}, got {other:?}"), + } } } + #[test] + fn audio_sample_rate_is_written_little_endian() { + let bytes = Header::for_audio(44_100, 2, AUDIO_BITS_PER_SAMPLE).write(); + assert_eq!( + &bytes[6..10], + &44_100u32.to_le_bytes(), + "sample rate must be little-endian" + ); + assert_eq!(bytes[10], 2, "channel count must follow the sample rate"); + assert_eq!( + bytes[11], AUDIO_BITS_PER_SAMPLE, + "bits per sample must follow the channel count" + ); + } + #[test] fn unknown_file_type_is_refused() { let mut bytes = valid_image_header(); @@ -348,7 +501,7 @@ mod tests { let parsed = Header::read(&bytes).expect("reserved bytes must be ignored"); assert_eq!(parsed.file_type, FileType::Image); assert_eq!( - parsed.dimensions, + parsed.image_dimensions(), Some(ImageDimensions { width: 640, height: 480 @@ -358,56 +511,49 @@ mod tests { #[test] fn parse_filename_reads_type_and_x() { - assert_eq!( - parse_filename(Path::new("photo.idcy3")).expect("idcy3 parses"), - (FileType::Image, 3.0) - ); - assert_eq!( - parse_filename(Path::new("note.tdcy12")).expect("tdcy12 parses"), - (FileType::Text, 12.0) - ); + for (name, expected) in [ + ("photo.idcy3", (FileType::Image, 3.0)), + ("note.tdcy12", (FileType::Text, 12.0)), + ("clip.adcy5", (FileType::Audio, 5.0)), + ] { + assert_eq!( + parse_filename(Path::new(name)).expect("valid name must parse"), + expected, + "'{name}' did not parse to its type and x" + ); + } } #[test] - fn parse_filename_refuses_unrecognized_extension() { + fn parse_filename_refuses_every_malformed_name() { + // Each way a name can fail is a distinct error, so a caller can tell an + // unknown extension from a missing x from an x out of range. for name in ["photo.png", "note.txt", "no_extension"] { assert!( matches!( parse_filename(Path::new(name)), Err(DecayError::UnrecognizedExtension { .. }) ), - "'{}' should be an unrecognized extension", - name + "'{name}' should be an unrecognized extension" ); } - } - #[test] - fn parse_filename_refuses_missing_or_non_numeric_x() { for name in ["photo.idcy", "note.tdcyx", "photo.idcy3a"] { assert!( matches!( parse_filename(Path::new(name)), Err(DecayError::FilenameNoX { .. }) ), - "'{}' should yield FilenameNoX", - name + "'{name}' should yield FilenameNoX" ); } - } - #[test] - fn parse_filename_refuses_zero_x() { assert!(matches!( parse_filename(Path::new("photo.idcy0")), Err(DecayError::XNotPositive { .. }) )); - } - #[test] - fn parse_filename_refuses_x_too_large_for_u32() { - // A run of digits that overflows u32 reports an out-of-range error, not the - // misleading "no x" error, since there clearly is an x, it is just too big. + // A run of digits that overflows u32 reports out-of-range, not "no x". assert!(matches!( parse_filename(Path::new("photo.idcy99999999999")), Err(DecayError::XOutOfRange { .. }) diff --git a/src/lib.rs b/src/lib.rs index 30547de..2f15834 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ //! decayfmt: a file format where decay is a first-class property. //! -//! This crate currently exposes the foundation of the format: the typed error -//! model, the binary header, and the corruption algorithm. These are the parts -//! that must be correct before any encode, open, or CLI code is built on top of -//! them. The CLI binary and its encode and open flows are added in later milestones. +//! Every open corrupts the payload on disk by an amount taken from the filename. +//! The crate exposes the whole format: the typed error model ([`error`]), the binary +//! header and filename convention ([`format`]), the corruption algorithm ([`corrupt`]), +//! and the two flows built on them, [`encode`] and [`open`]. Images, text, and audio +//! are supported. pub mod corrupt; pub mod encode; diff --git a/src/main.rs b/src/main.rs index 2e54b5a..e8c4e92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,17 +25,18 @@ struct Cli { /// The subcommands decayfmt exposes. #[derive(Subcommand)] enum Command { - /// Encode a source image or text file into a decayfmt file. + /// Encode a source image, text, or audio file into a decayfmt file. Encode { - /// Path to the source image or text file to encode. + /// Path to the source image, text, or audio file to encode. #[arg(long)] input: PathBuf, - /// Path to write the decayfmt file to, ending in .idcy or .tdcy. The - /// instability value x is taken from this name; higher x decays faster. + /// Path to write the decayfmt file to, ending in .idcy, .tdcy, or + /// .adcy. The instability value x is taken from this name; higher x + /// decays faster. #[arg(long)] output: PathBuf, }, - /// Open a decayfmt file: corrupt it in place on disk, then display it. + /// Open a decayfmt file: corrupt it in place on disk, then display or play it. Open { /// Path to the decayfmt file to open. x is read from its extension. file: PathBuf, @@ -46,6 +47,13 @@ enum Command { /// typed decayfmt error and exits with a failure code; on success, exits cleanly. fn main() -> ExitCode { let cli = Cli::parse(); + + // Sweep the temporary files left by the last display before doing anything else. + // Running this for every command, rather than only inside open, means an encode or + // a failed open also clears the snapshot of a past decay state that the previous + // display left in the temp directory. + decayfmt::open::cleanup_old_view_files(); + let result = match cli.command { Command::Encode { input, output } => decayfmt::encode::encode_file(&input, &output), Command::Open { file } => decayfmt::open::open_file(&file), diff --git a/src/open.rs b/src/open.rs index ded6fce..00e3e75 100644 --- a/src/open.rs +++ b/src/open.rs @@ -1,17 +1,22 @@ //! Open a decayfmt file: corrupt it in place on disk, then display it. //! //! This module owns the entire open flow and all of its file I/O. The order of -//! operations is the core of the format contract and must not be reordered: x is -//! parsed from the filename, the file is confirmed writable, the payload is -//! corrupted, the corrupted bytes are written back to disk, and only then is the -//! result displayed. Corruption is paid before display, so a crash mid-flow can -//! never hand back a free, uncorrupted read. +//! operations is fixed and must not be reordered: x is parsed from the filename, the +//! file is confirmed writable, the payload is corrupted, the corrupted bytes are +//! written back to disk, and only then is the result displayed. Corruption is written +//! before display, so a crash mid-flow cannot produce an uncorrupted read. The whole +//! read-modify-write sequence runs under an exclusive advisory lock on the file, so +//! overlapping opens each cost their own corruption instead of overwriting each other. use crate::corrupt::corrupt; use crate::error::DecayError; -use crate::format::{parse_filename, Header, ImageDimensions, HEADER_SIZE}; +use crate::format::{ + parse_filename, AudioSpec, Header, ImageDimensions, Metadata, AUDIO_BITS_PER_SAMPLE, + AUDIO_BYTES_PER_SAMPLE, HEADER_SIZE, +}; +use fs2::FileExt; use std::fs::OpenOptions; -use std::io::{IsTerminal, Seek, SeekFrom, Write}; +use std::io::{IsTerminal, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; @@ -21,9 +26,8 @@ const RGBA_BYTES_PER_PIXEL: usize = 4; /// Confirms the file can be written before any corruption is attempted. /// -/// The format contract is that opening costs a corruption. If the file is -/// read-only that corruption cannot be written, so we fail closed here, before -/// reading or displaying anything, rather than discovering it after a display. +/// A read-only file cannot receive its corrupted payload, so this check runs before +/// any read or display and fails closed. fn ensure_writable(path: &Path) -> Result<(), DecayError> { let metadata = std::fs::metadata(path).map_err(|error| DecayError::Io { context: format!("open: stat '{}'", path.display()), @@ -40,10 +44,10 @@ fn ensure_writable(path: &Path) -> Result<(), DecayError> { /// Corrupts a decayfmt file in place on disk and returns its header and the new /// file bytes. /// -/// This is the persisted half of the open flow and the part that upholds the -/// contract: parse x, verify writability, corrupt the payload, write it back. It -/// performs no display, so the corruption it commits never depends on anything -/// being shown. The header is read but never changed; only the payload is corrupted. +/// This is the persisted half of the open flow: parse x, verify writability, corrupt +/// the payload, write it back. It performs no display, so the corruption it writes is +/// independent of the display path. The header is read but never changed; only the +/// payload is corrupted. /// /// Public so embedding callers (such as the Python bindings) can commit an open /// without the display side of `open_file`. @@ -51,16 +55,37 @@ pub fn decay_in_place(path: &Path) -> Result<(Header, Vec), DecayError> { let (filename_type, x) = parse_filename(path)?; ensure_writable(path)?; - let mut file_bytes = std::fs::read(path).map_err(|error| DecayError::Io { - context: format!("open: read '{}'", path.display()), + // One handle serves the whole read, corrupt, and write-back sequence, held under an + // exclusive advisory lock. Reading and writing through separate handles would leave + // a window where two concurrent opens both read the same starting payload and the + // later write discarded the earlier one, so the two opens together cost a single + // corruption. The kernel releases the lock when the file is dropped or the process + // exits, so a crash cannot leave it held. + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|error| DecayError::Io { + context: format!("open: open '{}'", path.display()), + source: error, + })?; + file.lock_exclusive().map_err(|error| DecayError::Io { + context: format!("open: lock '{}' for a single open", path.display()), source: error, })?; + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes) + .map_err(|error| DecayError::Io { + context: format!("open: read '{}'", path.display()), + source: error, + })?; + let header = Header::read(&file_bytes)?; // The extension prefix and the header must agree on the payload type. If they // disagree, for example an image file renamed to a .tdcy name, refuse rather - // than trust one source over the other. + // than trusting either source. if filename_type != header.file_type { return Err(DecayError::MismatchedFileType { extension_kind: filename_type.label(), @@ -72,19 +97,12 @@ pub fn decay_in_place(path: &Path) -> Result<(Header, Vec), DecayError> { // and is left untouched; everything after it is the payload. corrupt(&mut file_bytes[HEADER_SIZE..], header.file_type, x); - // Persist the corruption by overwriting only the payload region in place. The - // header bytes on disk are never rewritten, matching the contract that the header - // is immutable after encode, and because corruption preserves length the file is - // never truncated. This is the point of no return: once the write lands the - // previous payload state is gone. A crash mid-write leaves a partially corrupted - // payload behind an intact header, so the file still decays rather than bricking. - let mut file = OpenOptions::new() - .write(true) - .open(path) - .map_err(|error| DecayError::Io { - context: format!("open: reopen for write '{}'", path.display()), - source: error, - })?; + // Persist the corruption by overwriting only the payload region in place, through + // the same locked handle the payload was read from. The header bytes on disk are + // never rewritten, and because corruption preserves length the file is never + // truncated. The previous payload bytes are not kept anywhere. A crash mid-write + // leaves a partially corrupted payload behind an intact header, so the file still + // decays rather than bricking. file.seek(SeekFrom::Start(HEADER_SIZE as u64)) .map_err(|error| DecayError::Io { context: format!("open: seek past header in '{}'", path.display()), @@ -101,16 +119,17 @@ pub fn decay_in_place(path: &Path) -> Result<(Header, Vec), DecayError> { /// Opens a decayfmt file: corrupts its payload in place on disk, then displays it. /// -/// Upholds the contract ordering: the file is corrupted and persisted first, then -/// displayed. Dimensions are present exactly for images, so their presence selects -/// the display path. +/// The file is corrupted and persisted first, then displayed. The header's per-type +/// metadata selects the display path, so each payload type is presented with the +/// parameters it recorded at encode time. pub fn open_file(path: &Path) -> Result<(), DecayError> { cleanup_old_view_files(); let (header, file_bytes) = decay_in_place(path)?; let payload = &file_bytes[HEADER_SIZE..]; - match header.dimensions { - Some(dimensions) => display_image(payload, dimensions), - None => display_text(payload), + match header.metadata { + Metadata::Image(dimensions) => display_image(payload, dimensions), + Metadata::Text => display_text(payload), + Metadata::Audio(spec) => play_audio(payload, spec), } } @@ -118,7 +137,7 @@ pub fn open_file(path: &Path) -> Result<(), DecayError> { /// /// The payload may no longer be valid UTF-8 after corruption, so it is rendered /// lossily: invalid byte sequences become the Unicode replacement character rather -/// than causing a failure. Corruption is allowed to break the text; display is not. +/// than causing a failure. /// /// The text is always written to stdout. When stdout is not a terminal, for example /// when decayfmt was launched from a file manager, that output goes nowhere, so the @@ -175,6 +194,81 @@ fn display_image(payload: &[u8], dimensions: ImageDimensions) -> Result<(), Deca open_in_default_app(&viewer_path) } +/// Re-encodes a corrupted PCM payload to a temporary WAV and opens it in the system's +/// default audio player. +/// +/// The raw payload carries no playback parameters of its own, so the header's sample +/// rate, channel count, and bit depth are required to interpret it. WAV is used for the +/// same reason PNG is used for images: hound is pure Rust, so the binary stays +/// self-contained, and an uncompressed container reproduces the decayed samples exactly +/// rather than running them through a lossy encoder that would add its own artifacts. +/// +/// A payload whose length is not a whole number of frames is rejected as a size +/// mismatch rather than played back partially. +fn play_audio(payload: &[u8], spec: AudioSpec) -> Result<(), DecayError> { + if spec.bits_per_sample != AUDIO_BITS_PER_SAMPLE { + return Err(DecayError::AudioEncode { + context: format!( + "open: header declares {} bits per sample but this build only writes and plays {}.", + spec.bits_per_sample, AUDIO_BITS_PER_SAMPLE + ), + }); + } + if spec.channels == 0 { + return Err(DecayError::AudioEncode { + context: "open: header declares zero audio channels.".to_string(), + }); + } + + // Every frame holds one sample per channel, so the payload must divide evenly into + // whole frames for the clip to be complete. + let bytes_per_frame = AUDIO_BYTES_PER_SAMPLE * spec.channels as usize; + // `manual_is_multiple_of` suggests `usize::is_multiple_of`, which is stable only + // from Rust 1.87; the remainder test keeps the minimum supported Rust version low, + // matching the same choice made in corrupt.rs. + #[allow(unknown_lints, clippy::manual_is_multiple_of)] + if payload.len() % bytes_per_frame != 0 { + let whole_frames = payload.len() / bytes_per_frame; + return Err(DecayError::PayloadSizeMismatch { + expected: whole_frames.saturating_add(1) * bytes_per_frame, + found: payload.len(), + }); + } + + let viewer_path = temporary_output_path("wav"); + let encode_error = |message: String| DecayError::AudioEncode { + context: format!( + "open: encode playback wav '{}': {}", + viewer_path.display(), + message + ), + }; + + let wav_spec = hound::WavSpec { + channels: u16::from(spec.channels), + sample_rate: spec.sample_rate, + bits_per_sample: u16::from(spec.bits_per_sample), + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::create(&viewer_path, wav_spec) + .map_err(|error| encode_error(error.to_string()))?; + // `chunks_exact_to_as_chunks` suggests `slice::as_chunks`, which is stable only from + // Rust 1.88; `chunks_exact` keeps the minimum supported Rust version low, matching + // the same choice made in corrupt.rs. + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] + for sample in payload.chunks_exact(AUDIO_BYTES_PER_SAMPLE) { + let value = i16::from_le_bytes([sample[0], sample[1]]); + writer + .write_sample(value) + .map_err(|error| encode_error(error.to_string()))?; + } + writer + .finalize() + .map_err(|error| encode_error(error.to_string()))?; + + open_in_default_app(&viewer_path) +} + /// Builds a unique path in the system temp directory for a display file with the /// given extension. The viewer is launched asynchronously so this file cannot be /// deleted immediately; instead every open sweeps the previous ones via @@ -190,12 +284,14 @@ fn temporary_output_path(extension: &str) -> PathBuf { /// Best-effort removal of the temporary view files left by previous opens. /// /// Displaying a corrupted payload requires writing it to a temporary file for the -/// system viewer, and those files linger as snapshots of past decay states. Since the -/// format's whole point is that there is no recovery to an earlier state, the tool -/// must not quietly leave recoverable copies of less-corrupted states lying around. On -/// each open we sweep the old ones. Failures are ignored: a file still held open by a -/// viewer simply survives until the next run. -fn cleanup_old_view_files() { +/// system viewer, and those files persist as copies of earlier, less-corrupted payload +/// states. Every command sweeps them, not just a successful open, so an encode or a +/// failed open also clears whatever the last display left behind. Failures are ignored: +/// a file still held open by a viewer simply survives until the next run. +/// +/// The file from the current open cannot be swept here, because the viewer is launched +/// asynchronously and needs it to outlive this process. +pub fn cleanup_old_view_files() { if let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) { for entry in entries.flatten() { if let Some(name) = entry.file_name().to_str() { @@ -212,8 +308,7 @@ fn cleanup_old_view_files() { /// /// Each platform exposes a different one-shot "open with the default application" /// command. The application is spawned and not waited on, so it stays open after -/// this returns. A failure to launch is reported, though by this point the -/// corruption has already been written to disk. +/// this returns. A failure to launch is reported as an error. fn open_in_default_app(path: &Path) -> Result<(), DecayError> { let spawn_result = if cfg!(target_os = "windows") { // On Windows, start is a cmd builtin; its first quoted argument is treated @@ -253,39 +348,169 @@ mod tests { std::env::temp_dir().join(format!("decayfmt_open_test_{}_{}", nanos, suffix)) } + /// Builds a minimal 16-bit PCM WAV in memory so the audio tests do not need a + /// fixture file on disk. `frames` is the number of samples per channel. + fn wav_bytes(sample_rate: u32, channels: u16, frames: u32) -> Vec { + let data_len = frames * u32::from(channels) * 2; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&channels.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * u32::from(channels) * 2).to_le_bytes()); + bytes.extend_from_slice(&(channels * 2).to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + for frame in 0..frames { + for _ in 0..channels { + // A ramp rather than silence, so a corrupted byte is a changed byte. + let sample = (frame % 30_000) as i16; + bytes.extend_from_slice(&sample.to_le_bytes()); + } + } + bytes + } + #[test] fn open_changes_the_payload_but_never_the_header_on_disk() { - // Encode a text file, capture its clean payload and header, open it, then - // confirm the payload bytes on disk changed while the header bytes did not. - let source = vec![b'a'; 4096]; - let input = unique_temp_path("source.txt"); - let decay_file = unique_temp_path("note.tdcy5"); - fs::write(&input, &source).expect("write test source"); - encode_file(&input, &decay_file).expect("encode should succeed"); - - let before = fs::read(&decay_file).expect("read encoded file"); - let header_before = before[..HEADER_SIZE].to_vec(); - let payload_before = before[HEADER_SIZE..].to_vec(); + // The core of the format: decay is written to disk, the header is untouched, + // and length is preserved. Checked for a text file and an audio file, since + // audio must also keep its duration, sample rate, and channel count. + let text_source = unique_temp_path("source.txt"); + fs::write(&text_source, vec![b'a'; 4096]).expect("write test source"); + let audio_source = unique_temp_path("source.wav"); + fs::write(&audio_source, wav_bytes(22_050, 1, 4_000)).expect("write test wav"); + + for (source, name) in [(&text_source, "note.tdcy5"), (&audio_source, "clip.adcy10")] { + let decay_file = unique_temp_path(name); + encode_file(source, &decay_file).expect("encode should succeed"); + + let before = fs::read(&decay_file).expect("read encoded file"); + // decay_in_place is the persisted half of open, without the display step, + // so the test exercises the corruption write without spawning a viewer. + decay_in_place(&decay_file).expect("decay should succeed"); + let after = fs::read(&decay_file).expect("read opened file"); + + assert_eq!( + &after[..HEADER_SIZE], + &before[..HEADER_SIZE], + "{name}: the header bytes on disk must be untouched by open" + ); + assert_ne!( + &after[HEADER_SIZE..], + &before[HEADER_SIZE..], + "{name}: payload must differ on disk after open" + ); + assert_eq!( + after.len(), + before.len(), + "{name}: in-place payload overwrite must not change the file length" + ); + + let _ = fs::remove_file(&decay_file); + } - // decay_in_place is the persisted half of open, without the display step, - // so the test exercises the corruption write without spawning a viewer. - decay_in_place(&decay_file).expect("decay should succeed"); + let _ = fs::remove_file(&text_source); + let _ = fs::remove_file(&audio_source); + } - let after = fs::read(&decay_file).expect("read opened file"); + #[test] + fn audio_round_trip_preserves_spec_and_payload_length() { + // The encoded payload must be exactly the source PCM, and the header must + // carry back the sample rate and channel count needed to play it. + let input = unique_temp_path("source.wav"); + let decay_file = unique_temp_path("clip.adcy3"); + fs::write(&input, wav_bytes(44_100, 2, 1_000)).expect("write test wav"); + encode_file(&input, &decay_file).expect("encode audio should succeed"); + + let encoded = fs::read(&decay_file).expect("read encoded file"); + let header = Header::read(&encoded).expect("audio header must parse"); + let spec = header.audio_spec().expect("audio header must carry a spec"); + assert_eq!(spec.sample_rate, 44_100); + assert_eq!(spec.channels, 2); + assert_eq!(spec.bits_per_sample, AUDIO_BITS_PER_SAMPLE); assert_eq!( - &after[..HEADER_SIZE], - header_before.as_slice(), - "the header bytes on disk must be untouched by open" + encoded.len() - HEADER_SIZE, + 1_000 * 2 * AUDIO_BYTES_PER_SAMPLE, + "payload must be exactly the decoded PCM, every frame of it" ); - assert_ne!( - &after[HEADER_SIZE..], - payload_before.as_slice(), - "payload must differ on disk after open" + + let _ = fs::remove_file(&input); + let _ = fs::remove_file(&decay_file); + } + + #[test] + fn sweeping_removes_stale_view_files() { + // A display file from a previous open is a copy of a less corrupted state, so + // the sweep must remove it. The current open's own file cannot be swept, since + // the viewer is launched asynchronously and needs it to outlive the process. + let stale = std::env::temp_dir().join(format!( + "decayfmt_view_{}.txt", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos() + )); + fs::write(&stale, b"a less corrupted state").expect("write stale view file"); + assert!(stale.exists(), "the stale file must exist before the sweep"); + + cleanup_old_view_files(); + + assert!( + !stale.exists(), + "the sweep must remove view files left by earlier opens" ); - assert_eq!( - after.len(), - before.len(), - "in-place payload overwrite must not change the file length" + } + + #[test] + fn concurrent_opens_each_cost_a_corruption() { + // Opening always costs a corruption, including when opens overlap. Corruption + // only ever replaces bytes, so from an all-'a' payload the count of surviving + // 'a' bytes falls with every open that lands. Without the exclusive lock two + // opens could read the same starting payload and the later write would discard + // the earlier one, leaving more survivors on disk than N sequential opens. + use std::sync::Barrier; + + const THREADS: usize = 4; + const PAYLOAD: usize = 4 * 1024 * 1024; + + let input = unique_temp_path("source.txt"); + let decay_file = unique_temp_path("note.tdcy10"); + fs::write(&input, vec![b'a'; PAYLOAD]).expect("write test source"); + encode_file(&input, &decay_file).expect("encode should succeed"); + + let barrier = Barrier::new(THREADS); + std::thread::scope(|scope| { + for _ in 0..THREADS { + let path = decay_file.clone(); + let barrier = &barrier; + scope.spawn(move || { + // Release every thread at once so the read-modify-write windows + // genuinely overlap. + barrier.wait(); + decay_in_place(&path).expect("decay should succeed"); + }); + } + }); + + let on_disk = fs::read(&decay_file).expect("read opened file"); + let survivors = on_disk[HEADER_SIZE..] + .iter() + .filter(|&&b| b == b'a') + .count(); + + // At x=10 each open replaces about 63% of bytes, so after THREADS opens the + // expected survival is 0.37^THREADS, about 1.9% here. Even one lost open would + // leave roughly 0.37^(THREADS-1), about 5%, so this bound separates them + // comfortably while staying far from the fully sequential expectation. + let survival = survivors as f64 / (PAYLOAD as f64); + assert!( + survival < 0.035, + "{survivors} of {PAYLOAD} bytes survived ({survival:.4}); an open was lost to a concurrent write" ); let _ = fs::remove_file(&input);