feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.
Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.
Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.
Two things the tests caught that review would not have:
- redact_headers recursed on its own output. The replacement keeps the
header NAME, so the next call matched the same header forever; the
test died with a stack overflow. It is a forward scan now.
- The frontend forwarder used `void plugin.error(...)`. `void` discards
a promise's value but not its rejection, so in any webview without
IPC -- a unit test, SSR, a browser preview -- every log line became an
unhandled rejection. 20 of them showed up the first time coverage
ran. Each call now attaches a catch.
Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.
Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.
The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.
Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.
Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
This commit is contained in:
Generated
+311
-2
@@ -49,6 +49,17 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -85,6 +96,23 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -159,6 +187,12 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
@@ -358,6 +392,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -389,6 +435,30 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -416,6 +486,40 @@ version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
version = "5.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
|
||||
dependencies = [
|
||||
"rust_decimal",
|
||||
"schemars 1.2.0",
|
||||
"serde",
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.24.0"
|
||||
@@ -1105,6 +1209,15 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fern"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1183,6 +1296,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1607,6 +1726,9 @@ name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1614,7 +1736,7 @@ version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.8.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2074,6 +2196,7 @@ dependencies = [
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
@@ -2086,6 +2209,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2305,6 +2429,9 @@ name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -2510,6 +2637,15 @@ dependencies = [
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
@@ -3239,6 +3375,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3318,6 +3474,12 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
@@ -3514,6 +3676,15 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -3569,6 +3740,35 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown 0.12.3",
|
||||
"ptr_meta",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -3583,6 +3783,23 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.7",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -3724,6 +3941,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -3955,6 +4178,12 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4257,6 +4486,12 @@ dependencies = [
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -4407,6 +4642,28 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"byte-unit",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"swift-rs",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.2"
|
||||
@@ -4486,7 +4743,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4689,7 +4946,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
@@ -5137,6 +5396,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5161,6 +5426,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -5973,6 +6244,15 @@ dependencies = [
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11"
|
||||
version = "2.21.0"
|
||||
@@ -6168,6 +6448,23 @@ dependencies = [
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.12.1",
|
||||
"memchr",
|
||||
"thiserror 2.0.17",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -6186,6 +6483,18 @@ version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.8.0"
|
||||
|
||||
@@ -62,6 +62,17 @@ sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Persistent, rotating, redacted logging on every platform -- and on Android the
|
||||
# only thing that puts Rust output into logcat at all (env_logger writes to
|
||||
# stdout, which Android discards, which is why the backend was invisible on the
|
||||
# platform where the hardest bugs live).
|
||||
#
|
||||
# TRACES: UR-078 | DR-218
|
||||
tauri-plugin-log = "2"
|
||||
|
||||
# Zip for the diagnostics export bundle.
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.9"
|
||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:path:default"
|
||||
"core:path:default",
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Diagnostics: log level control and the exportable bug-report bundle.
|
||||
//!
|
||||
//! TRACES: UR-078 | DR-218
|
||||
//!
|
||||
//! The app used to forget everything it did the moment it exited. A user
|
||||
//! reporting "the episode randomly restarted" was reporting the symptom of a
|
||||
//! race whose evidence had been discarded microseconds later, and the only way
|
||||
//! to recover it was to talk them through `adb logcat` — which is how several
|
||||
//! bugs in this project's history actually got diagnosed.
|
||||
//!
|
||||
//! This module is the other half of that: the log is on disk, it survives a
|
||||
//! crash, and the user can hand the whole thing over as one file.
|
||||
//!
|
||||
//! Everything written here has been through [`crate::utils::diagnostics::redact`]
|
||||
//! twice — once in the log formatter, and again on the way into the archive, so
|
||||
//! files written by a build that predates the formatter pass are covered too.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use log::{info, warn, LevelFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::utils::diagnostics::{redact, redact_server_url};
|
||||
|
||||
/// Where an export landed, so the UI can tell the user where to find it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticsBundle {
|
||||
/// Absolute path to the written archive.
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
/// How many log files went in, excluding the environment summary.
|
||||
pub file_count: usize,
|
||||
}
|
||||
|
||||
/// Where logs live and how verbose they currently are.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticsInfo {
|
||||
/// Directory holding the rotating log files.
|
||||
pub log_dir: String,
|
||||
/// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
|
||||
pub level: String,
|
||||
/// Total bytes currently held by log files.
|
||||
pub total_size_bytes: u64,
|
||||
}
|
||||
|
||||
/// Name of the file holding the user's chosen level, in the app config dir.
|
||||
const LEVEL_FILE: &str = "log-level";
|
||||
|
||||
/// Parse a stored/user-supplied level name.
|
||||
///
|
||||
/// Unknown values fall back to Info rather than erroring: this is read at
|
||||
/// startup, and a corrupt one-line file must not stop the app from launching.
|
||||
pub fn parse_level(raw: &str) -> LevelFilter {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"error" => LevelFilter::Error,
|
||||
"warn" => LevelFilter::Warn,
|
||||
"debug" => LevelFilter::Debug,
|
||||
"trace" => LevelFilter::Trace,
|
||||
_ => LevelFilter::Info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted level, if the user has ever set one.
|
||||
///
|
||||
/// Persisted rather than session-only on purpose: somebody reproducing a bug
|
||||
/// needs debug logging to survive *the restart that reproduces it*.
|
||||
pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
|
||||
fs::read_to_string(config_dir.join(LEVEL_FILE))
|
||||
.ok()
|
||||
.map(|raw| parse_level(&raw))
|
||||
}
|
||||
|
||||
fn level_name(level: LevelFilter) -> &'static str {
|
||||
match level {
|
||||
LevelFilter::Off => "off",
|
||||
LevelFilter::Error => "error",
|
||||
LevelFilter::Warn => "warn",
|
||||
LevelFilter::Info => "info",
|
||||
LevelFilter::Debug => "debug",
|
||||
LevelFilter::Trace => "trace",
|
||||
}
|
||||
}
|
||||
|
||||
/// Current log level and where the files are.
|
||||
///
|
||||
/// TRACES: UR-078 | DR-218
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
|
||||
let log_dir = app
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|e| format!("no log directory: {e}"))?;
|
||||
|
||||
let total_size_bytes = log_files(&log_dir)
|
||||
.iter()
|
||||
.filter_map(|p| fs::metadata(p).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
|
||||
Ok(DiagnosticsInfo {
|
||||
log_dir: log_dir.to_string_lossy().to_string(),
|
||||
level: level_name(log::max_level()).to_string(),
|
||||
total_size_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the log level, for this session and the next.
|
||||
///
|
||||
/// TRACES: UR-078 | DR-218
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
|
||||
let parsed = parse_level(&level);
|
||||
log::set_max_level(parsed);
|
||||
|
||||
let config_dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("no config directory: {e}"))?;
|
||||
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
|
||||
fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
|
||||
|
||||
info!("[DIAG] log level set to {}", level_name(parsed));
|
||||
Ok(level_name(parsed).to_string())
|
||||
}
|
||||
|
||||
/// Collect every log file in the log directory, newest first.
|
||||
fn log_files(log_dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = fs::read_dir(log_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut files: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.is_some_and(|ext| ext == "log" || ext == "txt")
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
files.reverse();
|
||||
files
|
||||
}
|
||||
|
||||
/// A short, non-identifying description of the environment.
|
||||
///
|
||||
/// Deliberately excludes the token, the username, and any path outside the
|
||||
/// app's own directories. The server URL is reduced to scheme and host, which is
|
||||
/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
|
||||
fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
|
||||
let package = app.package_info();
|
||||
let mut out = String::new();
|
||||
out.push_str("JellyTau diagnostics\n");
|
||||
out.push_str("====================\n\n");
|
||||
out.push_str(&format!("app version: {}\n", package.version));
|
||||
out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
|
||||
out.push_str(&format!("os: {}\n", std::env::consts::OS));
|
||||
out.push_str(&format!("arch: {}\n", std::env::consts::ARCH));
|
||||
out.push_str(&format!("debug build: {}\n", cfg!(debug_assertions)));
|
||||
out.push_str(&format!(
|
||||
"log level: {}\n",
|
||||
level_name(log::max_level())
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"server: {}\n",
|
||||
server_url.map_or("not configured".to_string(), redact_server_url)
|
||||
));
|
||||
out.push_str("\nNo access token, password or username is included in this file.\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Write a redacted diagnostics archive and return where it went.
|
||||
///
|
||||
/// # Blocking I/O
|
||||
///
|
||||
/// This reads and rewrites every log file. It is an `async` command so it does
|
||||
/// not block the IPC thread, but it must never be called from a player event
|
||||
/// callback — see the deadlock note in CLAUDE.md.
|
||||
///
|
||||
/// TRACES: UR-078 | DR-218
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn diagnostics_export(
|
||||
app: AppHandle,
|
||||
server_url: Option<String>,
|
||||
) -> Result<DiagnosticsBundle, String> {
|
||||
let log_dir = app
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|e| format!("no log directory: {e}"))?;
|
||||
|
||||
// Written into the app's own data directory. Choosing an arbitrary
|
||||
// user-visible location would need a file dialog on desktop and a storage
|
||||
// permission on Android; the UI reports the path and can reveal it.
|
||||
let out_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("no data directory: {e}"))?;
|
||||
fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let archive_path = out_dir.join("jellytau-diagnostics.zip");
|
||||
let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options: zip::write::FileOptions<'_, ()> =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let files = log_files(&log_dir);
|
||||
let mut written = 0usize;
|
||||
|
||||
for path in &files {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut contents = String::new();
|
||||
match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
// A log we cannot read is not a reason to produce no bundle.
|
||||
warn!("[DIAG] skipping unreadable log {name}: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Second redaction pass. The formatter already cleaned anything this
|
||||
// build wrote; this covers files left by an older build.
|
||||
let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
|
||||
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(cleaned.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
zip.start_file("environment.txt", options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
|
||||
let size_bytes = fs::metadata(&archive_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.len();
|
||||
info!(
|
||||
"[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
|
||||
archive_path.display()
|
||||
);
|
||||
|
||||
Ok(DiagnosticsBundle {
|
||||
path: archive_path.to_string_lossy().to_string(),
|
||||
size_bytes,
|
||||
file_count: written,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_every_level_name_case_insensitively() {
|
||||
assert_eq!(parse_level("debug"), LevelFilter::Debug);
|
||||
assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
|
||||
assert_eq!(parse_level(" warn\n"), LevelFilter::Warn);
|
||||
assert_eq!(parse_level("error"), LevelFilter::Error);
|
||||
assert_eq!(parse_level("trace"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_level_falls_back_to_info_rather_than_failing() {
|
||||
// This is read at startup from a file on disk. A corrupt value must not
|
||||
// stop the app launching.
|
||||
assert_eq!(parse_level("banana"), LevelFilter::Info);
|
||||
assert_eq!(parse_level(""), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_names_round_trip() {
|
||||
for name in ["error", "warn", "info", "debug", "trace"] {
|
||||
assert_eq!(level_name(parse_level(name)), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_level_is_none_when_never_set() {
|
||||
let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _ = fs::remove_file(dir.join(LEVEL_FILE));
|
||||
assert!(stored_level(&dir).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_level_reads_back_what_was_written() {
|
||||
let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
|
||||
assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
|
||||
let _ = fs::remove_file(dir.join(LEVEL_FILE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_files_ignores_non_log_files() {
|
||||
let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("jellytau.log"), "x").unwrap();
|
||||
fs::write(dir.join("notes.md"), "x").unwrap();
|
||||
fs::write(dir.join("jellytau.zip"), "x").unwrap();
|
||||
|
||||
let found = log_files(&dir);
|
||||
let names: Vec<String> = found
|
||||
.iter()
|
||||
.filter_map(|p| p.file_name()?.to_str().map(String::from))
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&"jellytau.log".to_string()));
|
||||
// The export archive itself lives elsewhere, but never re-zip a zip.
|
||||
assert!(!names.contains(&"jellytau.zip".to_string()));
|
||||
assert!(!names.contains(&"notes.md".to_string()));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod catalog;
|
||||
pub mod connectivity;
|
||||
pub mod conversions;
|
||||
pub mod device;
|
||||
pub mod diagnostics;
|
||||
pub mod download;
|
||||
pub mod favorites;
|
||||
pub mod library;
|
||||
@@ -25,6 +26,7 @@ pub use catalog::*;
|
||||
pub use connectivity::*;
|
||||
pub use conversions::*;
|
||||
pub use device::*;
|
||||
pub use diagnostics::*;
|
||||
pub use download::*;
|
||||
pub use library::*;
|
||||
pub use offline::*;
|
||||
|
||||
+82
-4
@@ -58,6 +58,10 @@ use commands::{
|
||||
// Device commands
|
||||
device_get_id,
|
||||
device_set_id,
|
||||
// Diagnostics commands
|
||||
diagnostics_export,
|
||||
diagnostics_get_info,
|
||||
diagnostics_set_level,
|
||||
download_album,
|
||||
download_item,
|
||||
download_item_and_start,
|
||||
@@ -993,6 +997,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
playlist_add_items,
|
||||
playlist_remove_items,
|
||||
playlist_move_item,
|
||||
// Diagnostics commands
|
||||
// TRACES: UR-078 | DR-218
|
||||
diagnostics_get_info,
|
||||
diagnostics_set_level,
|
||||
diagnostics_export,
|
||||
// Conversion commands
|
||||
format_time_seconds,
|
||||
format_time_seconds_long,
|
||||
@@ -1099,12 +1108,67 @@ fn set_env_if_unset(key: &str, value: &str) {
|
||||
/// through `convertFileSrc` again.
|
||||
///
|
||||
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
|
||||
/// Build the logging plugin.
|
||||
///
|
||||
/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
|
||||
/// was invisible to anyone who launched from a desktop icon, and worse than
|
||||
/// useless on Android: stdout is not logcat, so the Rust backend produced no
|
||||
/// visible output at all on the platform carrying the hardest bugs in this
|
||||
/// project's history (the autoplay deadlock, the truncated-stream restart, the
|
||||
/// background-audio stall). tauri-plugin-log routes to logcat there for free.
|
||||
///
|
||||
/// Three decisions worth keeping:
|
||||
///
|
||||
/// * **Every line goes through `redact` first.** A credential must never reach
|
||||
/// disk, not merely be stripped later when a bundle is exported — a file on
|
||||
/// the device is already the disclosure.
|
||||
/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
|
||||
/// long-running session fill a phone. One rotation keeps yesterday's evidence
|
||||
/// without unbounded growth.
|
||||
/// * **The level is read from disk.** Someone reproducing a bug needs debug
|
||||
/// logging to survive the restart that reproduces it.
|
||||
///
|
||||
/// TRACES: UR-078 | DR-218
|
||||
fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
use tauri_plugin_log::{Target, TargetKind};
|
||||
|
||||
let mut targets = vec![
|
||||
Target::new(TargetKind::Stdout),
|
||||
Target::new(TargetKind::LogDir {
|
||||
file_name: Some("jellytau".to_string()),
|
||||
}),
|
||||
];
|
||||
|
||||
// Rust lines in the webview console, so a developer sees both halves of the
|
||||
// app in one place. Dev only -- in a release build this would ship backend
|
||||
// logging into a console the user can open.
|
||||
if cfg!(debug_assertions) {
|
||||
targets.push(Target::new(TargetKind::Webview));
|
||||
}
|
||||
|
||||
tauri_plugin_log::Builder::new()
|
||||
.targets(targets)
|
||||
.level(log::LevelFilter::Info)
|
||||
.max_file_size(5 * 1024 * 1024)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
|
||||
.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"[{}][{}] {}",
|
||||
record.level(),
|
||||
record.target(),
|
||||
crate::utils::diagnostics::redact(&message.to_string())
|
||||
))
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Initialize logger
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
// Crash capture before anything else, so a panic during startup is recorded
|
||||
// rather than vanishing with the process.
|
||||
//
|
||||
// TRACES: UR-078 | DR-218
|
||||
crate::utils::diagnostics::install_panic_hook();
|
||||
|
||||
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
|
||||
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
|
||||
@@ -1121,6 +1185,7 @@ pub fn run() {
|
||||
let invoke_handler = builder.invoke_handler();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(build_log_plugin())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.invoke_handler(invoke_handler)
|
||||
@@ -1139,6 +1204,19 @@ pub fn run() {
|
||||
// replace an installed APK, and the frontend offers the releases
|
||||
// page there instead.
|
||||
//
|
||||
// Re-apply the log level the user last chose. Without this the
|
||||
// picker would only affect the running session -- and the whole
|
||||
// point of a persisted level is that somebody reproducing a bug
|
||||
// keeps debug logging across the restart that reproduces it.
|
||||
//
|
||||
// TRACES: UR-078 | DR-218
|
||||
if let Ok(config_dir) = app.path().app_config_dir() {
|
||||
if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
|
||||
log::set_max_level(level);
|
||||
log::info!("[DIAG] restored log level from settings: {level}");
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-077 | DR-217
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Credential redaction and crash capture for diagnostic logs.
|
||||
//!
|
||||
//! TRACES: UR-078 | DR-218
|
||||
//!
|
||||
//! ## Why redaction lives here and not at the export
|
||||
//!
|
||||
//! A diagnostic bundle is something a user attaches to a public bug report. If a
|
||||
//! Jellyfin access token can reach it, this feature is a credential-disclosure
|
||||
//! bug with a friendly button on it.
|
||||
//!
|
||||
//! So [`redact`] runs in the log *formatter* — the token never reaches disk —
|
||||
//! and again over every line the exporter copies, which covers files written by
|
||||
//! an older build that lacked the formatter pass. Redacting only at export would
|
||||
//! leave the secret sitting in a file on the device, which is exactly the thing
|
||||
//! we are trying not to do.
|
||||
//!
|
||||
//! ## What is deliberately NOT redacted
|
||||
//!
|
||||
//! Server host, item ids, filenames and paths inside the app's own directories
|
||||
//! all stay. They are not secrets and they are the entire diagnostic value of a
|
||||
//! log: a bundle scrubbed of them is one nobody can debug anything from.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Replacement for a redacted value.
|
||||
pub const REDACTED: &str = "[REDACTED]";
|
||||
|
||||
/// Query-string parameters whose value is a credential.
|
||||
///
|
||||
/// Jellyfin accepts the API key under several spellings depending on the
|
||||
/// endpoint and client generation, and this codebase has emitted more than one
|
||||
/// of them over time.
|
||||
const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
|
||||
|
||||
/// Header names whose value is a credential.
|
||||
const SECRET_HEADERS: &[&str] = &[
|
||||
"x-emby-token",
|
||||
"x-mediabrowser-token",
|
||||
"authorization",
|
||||
"x-emby-authorization",
|
||||
];
|
||||
|
||||
/// JSON keys whose value is a credential.
|
||||
const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
|
||||
|
||||
/// Strip credentials from one log line.
|
||||
///
|
||||
/// Idempotent: redacting an already-redacted line changes nothing, which matters
|
||||
/// because the exporter may re-process a file the formatter already cleaned.
|
||||
pub fn redact(line: &str) -> String {
|
||||
let mut out = redact_query_params(line);
|
||||
out = redact_headers(&out);
|
||||
out = redact_json_values(&out);
|
||||
out = redact_emby_auth(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// `?api_key=abc&x=1` -> `?api_key=[REDACTED]&x=1`
|
||||
///
|
||||
/// The value ends at the first character that cannot be part of one: `&`
|
||||
/// separates parameters, and whitespace/quotes mean the URL itself ended.
|
||||
fn redact_query_params(line: &str) -> String {
|
||||
let mut result = String::with_capacity(line.len());
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let bytes = line.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
let mut matched = None;
|
||||
for key in SECRET_QUERY_KEYS {
|
||||
// A key only counts when it is preceded by ? or & (or starts the
|
||||
// line), so a *word* like "token" inside prose is left alone.
|
||||
if lower[i..].starts_with(key) {
|
||||
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
|
||||
let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
|
||||
let after = i + key.len();
|
||||
if is_param_start && after < bytes.len() && bytes[after] == b'=' {
|
||||
matched = Some((*key, after + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match matched {
|
||||
Some((key, value_start)) => {
|
||||
result.push_str(&line[i..i + key.len()]);
|
||||
result.push('=');
|
||||
result.push_str(REDACTED);
|
||||
let mut end = value_start;
|
||||
while end < bytes.len()
|
||||
&& !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
None => {
|
||||
// Advance one whole char, not one byte: a UTF-8 boundary split
|
||||
// would panic on the slice above.
|
||||
let ch = line[i..].chars().next().unwrap_or('\0');
|
||||
result.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// `X-Emby-Token: abc` -> `X-Emby-Token: [REDACTED]`
|
||||
///
|
||||
/// Scans forward from a cursor rather than recursing on the rewritten string.
|
||||
/// The obvious recursive version does not terminate: the replacement keeps the
|
||||
/// header *name*, so the next call finds the same header again and recurses
|
||||
/// until the stack is gone. A test provoked exactly that.
|
||||
fn redact_headers(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut rest = line;
|
||||
|
||||
'outer: loop {
|
||||
let lower = rest.to_ascii_lowercase();
|
||||
|
||||
// Earliest header match in what remains, so several headers on one line
|
||||
// are handled left to right.
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for header in SECRET_HEADERS {
|
||||
let needle = format!("{header}:");
|
||||
if let Some(pos) = lower.find(&needle) {
|
||||
let candidate = (pos, needle.len());
|
||||
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((pos, needle_len)) = best else {
|
||||
break 'outer;
|
||||
};
|
||||
|
||||
let value_start = pos + needle_len;
|
||||
// The value runs to the next comma or the end of the line: reqwest's
|
||||
// debug output prints several headers comma-separated on one line.
|
||||
let value_end = rest[value_start..]
|
||||
.find(',')
|
||||
.map_or(rest.len(), |c| value_start + c);
|
||||
|
||||
out.push_str(&rest[..value_start]);
|
||||
out.push(' ');
|
||||
out.push_str(REDACTED);
|
||||
|
||||
// Continue strictly *after* the value just handled -- this is what makes
|
||||
// the loop terminate.
|
||||
rest = &rest[value_end..];
|
||||
}
|
||||
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `"AccessToken":"abc"` -> `"AccessToken":"[REDACTED]"`
|
||||
fn redact_json_values(line: &str) -> String {
|
||||
let mut out = Cow::Borrowed(line);
|
||||
|
||||
for key in SECRET_JSON_KEYS {
|
||||
loop {
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let pattern = format!("\"{key}\"");
|
||||
let Some(key_pos) = lower.find(&pattern) else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Find the opening quote of the value after the colon.
|
||||
let after_key = key_pos + pattern.len();
|
||||
let Some(colon_rel) = out[after_key..].find(':') else {
|
||||
break;
|
||||
};
|
||||
let value_region = after_key + colon_rel + 1;
|
||||
let Some(open_rel) = out[value_region..].find('"') else {
|
||||
break;
|
||||
};
|
||||
let open = value_region + open_rel;
|
||||
let Some(close_rel) = out[open + 1..].find('"') else {
|
||||
break;
|
||||
};
|
||||
let close = open + 1 + close_rel;
|
||||
|
||||
// Already redacted: stop, or this loops forever.
|
||||
if &out[open + 1..close] == REDACTED {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut replaced = String::with_capacity(out.len());
|
||||
replaced.push_str(&out[..open + 1]);
|
||||
replaced.push_str(REDACTED);
|
||||
replaced.push_str(&out[close..]);
|
||||
out = Cow::Owned(replaced);
|
||||
}
|
||||
}
|
||||
|
||||
out.into_owned()
|
||||
}
|
||||
|
||||
/// `MediaBrowser Token="abc"` -> `MediaBrowser Token="[REDACTED]"`
|
||||
///
|
||||
/// Jellyfin's own auth header format, which is not JSON and not a query param.
|
||||
fn redact_emby_auth(line: &str) -> String {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let Some(pos) = lower.find("token=\"") else {
|
||||
return line.to_string();
|
||||
};
|
||||
let open = pos + "token=\"".len();
|
||||
let Some(close_rel) = line[open..].find('"') else {
|
||||
return line.to_string();
|
||||
};
|
||||
let close = open + close_rel;
|
||||
if &line[open..close] == REDACTED {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len());
|
||||
out.push_str(&line[..open]);
|
||||
out.push_str(REDACTED);
|
||||
out.push_str(&line[close..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reduce a server URL to scheme and host.
|
||||
///
|
||||
/// The host is diagnostic (is it https? a LAN address? a reverse proxy?); the
|
||||
/// path and any query on it are not, and a configured URL has been seen to carry
|
||||
/// a token.
|
||||
pub fn redact_server_url(url: &str) -> String {
|
||||
let Some(scheme_end) = url.find("://") else {
|
||||
return REDACTED.to_string();
|
||||
};
|
||||
let after_scheme = scheme_end + 3;
|
||||
let host_end = url[after_scheme..]
|
||||
.find('/')
|
||||
.map_or(url.len(), |slash| after_scheme + slash);
|
||||
// Credentials embedded as user:pass@host must not survive.
|
||||
let host = &url[after_scheme..host_end];
|
||||
let host = host.rsplit('@').next().unwrap_or(host);
|
||||
format!("{}://{}", &url[..scheme_end], host)
|
||||
}
|
||||
|
||||
/// Install a panic hook that records the panic through `log::error!` before the
|
||||
/// default hook runs.
|
||||
///
|
||||
/// # Why it chains rather than replaces
|
||||
///
|
||||
/// `utils::lock` installs a silencing hook around its own tests, which
|
||||
/// deliberately provoke poisoned locks. Replacing the current hook here would
|
||||
/// make that test output scream about panics it is intentionally causing — and,
|
||||
/// more importantly, replacing whatever hook is present is how you lose the
|
||||
/// backtrace the runtime would otherwise print.
|
||||
pub fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// The payload is very often the formatted message of a `panic!`, so it
|
||||
// goes through redaction like any other line: a panic inside the HTTP
|
||||
// layer can carry a URL.
|
||||
let payload = panic_payload_string(info);
|
||||
let location = info
|
||||
.location()
|
||||
.map(|l| format!("{}:{}", l.file(), l.line()))
|
||||
.unwrap_or_else(|| "unknown location".to_string());
|
||||
|
||||
log::error!("PANIC at {location}: {}", redact(&payload));
|
||||
log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
|
||||
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Extract a printable message from a panic payload.
|
||||
fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
|
||||
let payload = info.payload();
|
||||
if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_api_key_query_parameter() {
|
||||
let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
|
||||
let out = redact(line);
|
||||
assert!(!out.contains("abc123def"), "token survived: {out}");
|
||||
assert!(out.contains("api_key=[REDACTED]"));
|
||||
// The rest of the URL is what makes the line worth keeping.
|
||||
assert!(out.contains("media.example.com"));
|
||||
assert!(out.contains("Limit=50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_every_spelling_of_the_key_parameter() {
|
||||
for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
|
||||
let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
|
||||
let out = redact(&line);
|
||||
assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
|
||||
assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_auth_headers() {
|
||||
let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
|
||||
assert!(!out.contains("abc123"), "{out}");
|
||||
// A following header must survive -- the value stops at the comma.
|
||||
assert!(out.contains("Accept: application/json"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_authorization_header() {
|
||||
let out = redact("Authorization: Bearer verysecrettoken");
|
||||
assert!(!out.contains("verysecrettoken"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_json_access_token() {
|
||||
let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
|
||||
assert!(!out.contains("abc123"), "{out}");
|
||||
// The username is not a credential and is diagnostic.
|
||||
assert!(out.contains("duncan"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_the_emby_auth_header_form() {
|
||||
let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
|
||||
let out = redact(line);
|
||||
assert!(!out.contains("abc123xyz"), "{out}");
|
||||
assert!(out.contains("JellyTau"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_idempotent() {
|
||||
// The exporter re-processes files the formatter already cleaned; a
|
||||
// second pass must not corrupt them or loop.
|
||||
let once = redact("https://h/Items?api_key=abc&z=1");
|
||||
let twice = redact(&once);
|
||||
assert_eq!(once, twice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_ordinary_lines_untouched() {
|
||||
let line = "player: advancing to next episode (item 4f2a, position 0)";
|
||||
assert_eq!(redact(line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_redact_the_word_token_in_prose() {
|
||||
// "token" appears in comments and messages constantly. Only a real
|
||||
// parameter or header assignment should trigger.
|
||||
let line = "refreshing the access token because the session expired";
|
||||
assert_eq!(redact(line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_multibyte_characters_without_panicking() {
|
||||
// The scanner walks bytes; a naive implementation slices mid-character.
|
||||
let line = "playing “Où est le café” from https://h/Items?api_key=abc";
|
||||
let out = redact(line);
|
||||
assert!(!out.contains("abc"), "{out}");
|
||||
assert!(out.contains("café"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_keeps_scheme_and_host_only() {
|
||||
assert_eq!(
|
||||
redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
|
||||
"https://media.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_server_url("http://192.168.1.10:8096/"),
|
||||
"http://192.168.1.10:8096"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_drops_embedded_credentials() {
|
||||
// http://user:password@host is a valid URL and has been pasted into
|
||||
// server-address fields before.
|
||||
assert_eq!(
|
||||
redact_server_url("https://duncan:hunter2@media.example.com/"),
|
||||
"https://media.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
|
||||
assert_eq!(redact_server_url("media.example.com"), REDACTED);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod conversions;
|
||||
pub mod diagnostics;
|
||||
pub mod lock;
|
||||
|
||||
Reference in New Issue
Block a user